From ba90dee9d3edd72c34d9dc2299c93e894d8bf024 Mon Sep 17 00:00:00 2001 From: Bakare Faruq Date: Tue, 28 Apr 2026 23:16:22 +0100 Subject: [PATCH] Build bulk payout dashboard flow --- .../api/src/modules/payouts/payouts.module.ts | 3 +- .../src/modules/payouts/payouts.service.ts | 20 +- .../src/app/(dashboard)/payouts/page.tsx | 852 +++++++++++++++++- .../dashboard/src/hooks/useDashboardSocket.ts | 9 +- 4 files changed, 858 insertions(+), 26 deletions(-) diff --git a/apps/api/src/modules/payouts/payouts.module.ts b/apps/api/src/modules/payouts/payouts.module.ts index 581fcc7..b3be4b6 100644 --- a/apps/api/src/modules/payouts/payouts.module.ts +++ b/apps/api/src/modules/payouts/payouts.module.ts @@ -5,9 +5,10 @@ import { PrismaModule } from '../prisma/prisma.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; import { StellarModule } from '../stellar/stellar.module'; import { AuthModule } from '../auth/auth.module'; +import { EventsModule } from '../events/events.module'; @Module({ - imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule], + imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule, EventsModule], providers: [PayoutsService], controllers: [PayoutsController], exports: [PayoutsService], diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts index 618ed05..20bc66b 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -9,6 +9,7 @@ import { DestType, Payout, PayoutStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { StellarService } from '../stellar/stellar.service'; +import { EventsService } from '../events/events/events.service'; import { CreatePayoutDto, BulkPayoutDto } from './dto/create-payout.dto'; import { PayoutFiltersDto } from './dto/payout-filters.dto'; import { randomUUID } from 'crypto'; @@ -42,6 +43,7 @@ export class PayoutsService { private readonly prisma: PrismaService, private readonly webhooks: WebhooksService, private readonly stellar: StellarService, + private readonly events: EventsService, ) {} // ── Create single payout ────────────────────────────────────────────────── @@ -81,6 +83,7 @@ export class PayoutsService { this.webhooks .dispatch(merchantId, 'payout.initiated', this.webhookPayload(payout) as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(payout); // Process immediately unless scheduled for the future if (!payout.scheduledAt || payout.scheduledAt <= new Date()) { @@ -116,6 +119,7 @@ export class PayoutsService { }); results.push({ index: i, payoutId: payout.id }); accepted++; + this.emitPayoutStatus(payout); if (!payout.scheduledAt || payout.scheduledAt <= new Date()) { this.processPayout(payout).catch(() => undefined); @@ -220,6 +224,7 @@ export class PayoutsService { this.webhooks .dispatch(merchantId, 'payout.initiated', this.webhookPayload(reset) as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(reset); this.processPayout(reset).catch(() => undefined); @@ -229,10 +234,11 @@ export class PayoutsService { // ── Internal processing ─────────────────────────────────────────────────── private async processPayout(payout: Payout): Promise { - await this.prisma.payout.update({ + const processing = await this.prisma.payout.update({ where: { id: payout.id }, data: { status: PayoutStatus.PROCESSING }, }); + this.emitPayoutStatus(processing); try { const destination = payout.destination as Record; @@ -260,6 +266,7 @@ export class PayoutsService { failureReason, } as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(failed); this.logger.error(`Payout ${payout.id} failed: ${failureReason}`); } } @@ -313,6 +320,7 @@ export class PayoutsService { stellarTxHash: txHash, } as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(completed); } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -349,4 +357,14 @@ export class PayoutsService { createdAt: payout.createdAt.toISOString(), }; } + + private emitPayoutStatus(payout: Payout): void { + this.events.emitPayoutStatus(payout.merchantId, payout.id, payout.status, { + amount: payout.amount.toString(), + currency: payout.currency, + stellarTxHash: payout.stellarTxHash ?? undefined, + failureReason: payout.failureReason ?? undefined, + updatedAt: new Date(), + }); + } } diff --git a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx index db93572..67e6c7e 100644 --- a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx @@ -1,38 +1,848 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AlertCircle, + CheckCircle2, + Download, + FileSpreadsheet, + Pencil, + RefreshCcw, + RotateCcw, + ShieldCheck, + UploadCloud, +} from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@useroutr/ui"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { useDashboardSocket } from "@/hooks/useDashboardSocket"; + +const REQUIRED_HEADERS = [ + "recipient_name", + "destination_type", + "account_details", + "amount", + "currency", +] as const; + +const SUPPORTED_BANK_COUNTRIES = ["US", "GB", "NG", "KE", "GH", "ZA"]; +const SUPPORTED_MOBILE_COUNTRIES = ["NG", "KE", "GH", "ZA", "UG", "TZ", "RW"]; +const TWO_FACTOR_ENABLED = true; +const FEE_RATE = 0.012; + +type DestinationType = "BANK_ACCOUNT" | "MOBILE_MONEY" | "CRYPTO_WALLET" | "STELLAR"; +type ValidationStatus = "valid" | "error"; +type PayoutStatus = "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED" | "CANCELLED"; + +interface EditableRow { + id: string; + rowNumber: number; + recipient_name: string; + destination_type: string; + account_details: string; + amount: string; + currency: string; + status: ValidationStatus; + errors: string[]; +} + +interface PayoutPayload { + recipientName: string; + destinationType: DestinationType; + destination: Record; + amount: string; + currency: string; +} + +interface BulkPayoutResult { + batchId: string; + total: number; + accepted: number; + rejected: number; + payouts: Array<{ index: number; payoutId?: string; error?: string }>; +} + +interface PayoutListItem { + id: string; + recipientName: string; + destinationType: DestinationType; + amount: string; + currency: string; + status: PayoutStatus; + failureReason?: string | null; + batchId?: string | null; +} + +interface PayoutsResponse { + data: PayoutListItem[]; + total: number; +} + +interface ProgressRecipient { + rowId: string; + payoutId?: string; + recipientName: string; + amount: string; + currency: string; + status: PayoutStatus; + failureReason?: string; +} + +interface SocketEnvelope { + event?: string; + data?: { + payoutId?: string; + status?: PayoutStatus; + failureReason?: string; + }; +} + +const templateCsv = `${REQUIRED_HEADERS.join(",")} +Amina Bello,BANK_ACCOUNT,"accountNumber=1234567890;bankName=Kuda;country=NG",2500,NGN +Kwame Mensah,MOBILE_MONEY,"phoneNumber=+233241234567;provider=MTN;country=GH",450,GHS +Nova Labs,STELLAR,"address=GBZXN7PIRZGNMHGAQXUKYOOSHLJXBS5M4NUJ2S7NGW3MY6RIM7E6WYOG;asset=native",75,USD`; + +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let current = ""; + let row: string[] = []; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + + if (char === '"' && inQuotes && next === '"') { + current += '"'; + i++; + continue; + } + + if (char === '"') { + inQuotes = !inQuotes; + continue; + } + + if (char === "," && !inQuotes) { + row.push(current.trim()); + current = ""; + continue; + } + + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && next === "\n") i++; + row.push(current.trim()); + if (row.some(Boolean)) rows.push(row); + row = []; + current = ""; + continue; + } + + current += char; + } + + row.push(current.trim()); + if (row.some(Boolean)) rows.push(row); + return rows; +} + +function parseAccountDetails(value: string): Record { + const trimmed = value.trim(); + if (!trimmed) return {}; + + try { + const parsed = JSON.parse(trimmed) as Record; + return Object.fromEntries( + Object.entries(parsed).map(([key, item]) => [key, String(item)]) + ); + } catch { + return Object.fromEntries( + trimmed + .split(";") + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const [key, ...rest] = part.split("="); + return [key.trim(), rest.join("=").trim()]; + }) + .filter(([key, item]) => key && item) + ); + } +} + +function normalizeDestinationType(value: string): DestinationType | null { + const normalized = value.trim().toUpperCase().replace(/[\s-]+/g, "_"); + if (["BANK_ACCOUNT", "MOBILE_MONEY", "CRYPTO_WALLET", "STELLAR"].includes(normalized)) { + return normalized as DestinationType; + } + return null; +} + +function validateRow(row: Omit): EditableRow { + const errors: string[] = []; + const destinationType = normalizeDestinationType(row.destination_type); + const details = parseAccountDetails(row.account_details); + const amount = Number(row.amount); + const currency = row.currency.trim().toUpperCase(); + + if (!row.recipient_name.trim()) errors.push("Recipient name is required"); + if (!destinationType) errors.push("Unsupported destination type"); + if (!Number.isFinite(amount) || amount <= 0) errors.push("Amount must be greater than 0"); + if (!/^[A-Z]{3}$/.test(currency)) errors.push("Currency must be a 3-letter code"); + + if (destinationType === "BANK_ACCOUNT") { + const country = details.country?.toUpperCase(); + if (!country || !SUPPORTED_BANK_COUNTRIES.includes(country)) { + errors.push("Unsupported country for bank payout"); + } + if (!/^\d{6,20}$/.test(details.accountNumber ?? "")) { + errors.push("Invalid account number"); + } + } + + if (destinationType === "MOBILE_MONEY") { + const country = details.country?.toUpperCase(); + if (!country || !SUPPORTED_MOBILE_COUNTRIES.includes(country)) { + errors.push("Unsupported country for mobile money"); + } + if (!/^\+?\d{7,15}$/.test(details.phoneNumber ?? "")) { + errors.push("Invalid mobile money phone number"); + } + if (!details.provider) errors.push("Mobile money provider is required"); + } + + if (destinationType === "CRYPTO_WALLET") { + if (!details.address || details.address.length < 12) errors.push("Invalid wallet address"); + if (!details.network) errors.push("Wallet network is required"); + } + + if (destinationType === "STELLAR") { + if (!/^G[A-Z2-7]{55}$/.test(details.address ?? "")) { + errors.push("Invalid Stellar account"); + } + } + + return { + ...row, + destination_type: destinationType ?? row.destination_type, + currency, + status: errors.length ? "error" : "valid", + errors, + }; +} + +function rowToPayout(row: EditableRow): PayoutPayload { + const destinationType = normalizeDestinationType(row.destination_type) as DestinationType; + const details = parseAccountDetails(row.account_details); + + return { + recipientName: row.recipient_name.trim(), + destinationType, + destination: { + ...details, + type: destinationType, + ...(destinationType === "BANK_ACCOUNT" || destinationType === "MOBILE_MONEY" + ? { country: details.country.toUpperCase() } + : {}), + ...(destinationType === "STELLAR" && !details.asset ? { asset: "native" } : {}), + ...(destinationType === "CRYPTO_WALLET" && !details.asset + ? { asset: row.currency.trim().toUpperCase() } + : {}), + }, + amount: Number(row.amount).toFixed(2), + currency: row.currency.trim().toUpperCase(), + }; +} + +function formatMoney(amount: number, currency = "USD") { + return new Intl.NumberFormat("en", { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(amount); +} + +function formatTotals(totals: Record) { + const entries = Object.entries(totals).filter(([, amount]) => amount > 0); + if (!entries.length) return formatMoney(0, "USD"); + return entries.map(([currency, amount]) => formatMoney(amount, currency)).join(" + "); +} + +function statusClass(status: PayoutStatus) { + if (status === "COMPLETED") return "bg-emerald-100 text-emerald-700"; + if (status === "FAILED") return "bg-red-100 text-red-700"; + if (status === "PROCESSING") return "bg-blue-100 text-blue-700"; + if (status === "CANCELLED") return "bg-zinc-100 text-zinc-600"; + return "bg-amber-100 text-amber-700"; +} export default function PayoutsPage() { + const fileInputRef = useRef(null); + const [activeTab, setActiveTab] = useState<"single" | "bulk">("bulk"); + const [rows, setRows] = useState([]); + const [fileName, setFileName] = useState(""); + const [uploadError, setUploadError] = useState(""); + const [editingId, setEditingId] = useState(null); + const [twoFactorCode, setTwoFactorCode] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + const [progress, setProgress] = useState([]); + + const { connected, subscribe } = useDashboardSocket(); + + const { data: payouts } = useQuery({ + queryKey: ["payouts", "recent"], + queryFn: () => api.get("/payouts", { params: { limit: 100, offset: 0 } }), + }); + + const validRows = rows.filter((row) => row.status === "valid"); + const invalidRows = rows.filter((row) => row.status === "error"); + const totalsByCurrency = validRows.reduce>((totals, row) => { + const currency = row.currency || "USD"; + totals[currency] = (totals[currency] ?? 0) + Number(row.amount || 0); + return totals; + }, {}); + const feesByCurrency = Object.fromEntries( + Object.entries(totalsByCurrency).map(([currency, amount]) => [currency, amount * FEE_RATE]) + ); + const debitByCurrency = Object.fromEntries( + Object.entries(totalsByCurrency).map(([currency, amount]) => [ + currency, + amount + (feesByCurrency[currency] ?? 0), + ]) + ); + const completedCount = progress.filter((item) => item.status === "COMPLETED").length; + const finishedCount = progress.filter((item) => ["COMPLETED", "FAILED"].includes(item.status)).length; + const progressPercent = progress.length ? Math.round((finishedCount / progress.length) * 100) : 0; + + const summaryCards = useMemo(() => { + const data = payouts?.data ?? []; + const pending = data.filter((item) => item.status === "PENDING" || item.status === "PROCESSING").length; + const paidOut = data + .filter((item) => item.status === "COMPLETED") + .reduce((sum, item) => sum + Number(item.amount), 0); + + return [ + { label: "Available balance", value: formatMoney(128_450, "USD") }, + { label: "Pending", value: String(pending) }, + { label: "Total paid out", value: formatMoney(paidOut || 0, "USD") }, + ]; + }, [payouts]); + + useEffect(() => { + return subscribe("message", (payload) => { + const envelope = payload as SocketEnvelope; + if (envelope.event !== "payout:status" || !envelope.data?.payoutId || !envelope.data.status) return; + + setProgress((current) => + current.map((recipient) => + recipient.payoutId === envelope.data?.payoutId + ? { + ...recipient, + status: envelope.data.status, + failureReason: envelope.data.failureReason ?? recipient.failureReason, + } + : recipient + ) + ); + }); + }, [subscribe]); + + function loadCsv(text: string, name: string) { + setUploadError(""); + setSubmitError(""); + setProgress([]); + + const parsed = parseCsv(text); + const [headers, ...body] = parsed; + const normalizedHeaders = headers?.map((header) => header.trim().toLowerCase()) ?? []; + const missing = REQUIRED_HEADERS.filter((header) => !normalizedHeaders.includes(header)); + + if (!headers?.length || missing.length) { + setRows([]); + setUploadError(`Missing columns: ${missing.join(", ")}`); + return; + } + + const nextRows = body.map((values, index) => { + const raw = Object.fromEntries( + REQUIRED_HEADERS.map((header) => [ + header, + values[normalizedHeaders.indexOf(header)] ?? "", + ]) + ) as Record<(typeof REQUIRED_HEADERS)[number], string>; + + return validateRow({ + id: `${Date.now()}-${index}`, + rowNumber: index + 2, + recipient_name: raw.recipient_name, + destination_type: raw.destination_type, + account_details: raw.account_details, + amount: raw.amount, + currency: raw.currency, + }); + }); + + setRows(nextRows); + setFileName(name); + } + + async function handleFile(file: File) { + if (!file.name.toLowerCase().endsWith(".csv")) { + setUploadError("Upload a CSV file"); + return; + } + loadCsv(await file.text(), file.name); + } + + function updateRow(id: string, field: (typeof REQUIRED_HEADERS)[number], value: string) { + setRows((current) => + current.map((row) => { + if (row.id !== id) return row; + return validateRow({ + id: row.id, + rowNumber: row.rowNumber, + recipient_name: field === "recipient_name" ? value : row.recipient_name, + destination_type: field === "destination_type" ? value : row.destination_type, + account_details: field === "account_details" ? value : row.account_details, + amount: field === "amount" ? value : row.amount, + currency: field === "currency" ? value : row.currency, + }); + }) + ); + } + + function downloadTemplate() { + const blob = new Blob([templateCsv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "useroutr-bulk-payout-template.csv"; + link.click(); + URL.revokeObjectURL(url); + } + + async function submitBulkPayout() { + setSubmitError(""); + if (!validRows.length || invalidRows.length) return; + if (TWO_FACTOR_ENABLED && !/^\d{6}$/.test(twoFactorCode)) { + setSubmitError("Enter the 6-digit 2FA code to confirm this payout"); + return; + } + + setSubmitting(true); + try { + const result = await api.post("/payouts/bulk", { + payouts: validRows.map(rowToPayout), + }); + + setProgress( + validRows.map((row, index) => { + const item = result.payouts.find((payout) => payout.index === index); + return { + rowId: row.id, + payoutId: item?.payoutId, + recipientName: row.recipient_name, + amount: row.amount, + currency: row.currency, + status: item?.error ? "FAILED" : "PENDING", + failureReason: item?.error, + }; + }) + ); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Bulk payout failed"); + } finally { + setSubmitting(false); + } + } + + async function retryRecipient(recipient: ProgressRecipient) { + if (!recipient.payoutId) return; + setProgress((current) => + current.map((item) => + item.rowId === recipient.rowId + ? { ...item, status: "PENDING", failureReason: undefined } + : item + ) + ); + try { + await api.post(`/payouts/${recipient.payoutId}/retry`); + } catch (error) { + setProgress((current) => + current.map((item) => + item.rowId === recipient.rowId + ? { + ...item, + status: "FAILED", + failureReason: error instanceof Error ? error.message : "Retry failed", + } + : item + ) + ); + } + } + return (
-
-

- Payouts -

- +
+
+

Payouts

+

+ Create and monitor recipient disbursements. +

+
+
+ {(["single", "bulk"] as const).map((tab) => ( + + ))} +
- {/* Summary cards */}
- {["Available balance", "Pending", "Total paid out"].map((label) => ( -
-

{label}

-
+ {summaryCards.map((card) => ( +
+

{card.label}

+

{card.value}

))}
- {/* Table placeholder */} -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
+ {activeTab === "single" ? ( +
+ {["Recipient", "Destination", "Amount", "Currency"].map((label) => ( + ))} +
+ +
-
+ ) : ( +
+
{ + event.preventDefault(); + const file = event.dataTransfer.files[0]; + if (file) void handleFile(file); + }} + onDragOver={(event) => event.preventDefault()} + className="rounded-lg border border-dashed border-border bg-card p-8 text-center shadow-sm" + > + { + const file = event.target.files?.[0]; + if (file) void handleFile(file); + }} + /> +
+ +
+

+ {fileName || "Drop CSV here or choose a file"} +

+

+ recipient_name, destination_type, account_details, amount, currency +

+
+ + + {!!rows.length && ( + + )} +
+ {uploadError && ( +

{uploadError}

+ )} +
+ + {!!rows.length && ( + <> +
+
+
+

CSV validation

+

+ {validRows.length} valid, {invalidRows.length} need fixes +

+
+
+ + + Valid + + + + Error + +
+
+ +
+ + + + + {REQUIRED_HEADERS.map((header) => ( + + ))} + + + + + {rows.map((row) => { + const editing = editingId === row.id; + return ( + + + {REQUIRED_HEADERS.map((field) => ( + + ))} + + + + ); + })} + +
Row + {header} + Errors +
{row.rowNumber} + {editing ? ( + updateRow(row.id, field, event.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring" + /> + ) : ( + + {row[field]} + + )} + + {row.errors.length ? ( +
+ {row.errors.map((error) => ( +

+ {error} +

+ ))} +
+ ) : ( + Ready + )} +
+ +
+
+
+ +
+
+

Confirmation summary

+
+
+

Total amount

+

+ {formatTotals(totalsByCurrency)} +

+
+
+

Recipients

+

{validRows.length}

+
+
+

Estimated fees

+

+ {formatTotals(feesByCurrency)} +

+
+
+

Settlement time

+

1-2 days

+
+
+ + {TWO_FACTOR_ENABLED && ( +
+ + + setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} + inputMode="numeric" + placeholder="000000" + className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm tracking-[0.2em] outline-none focus:ring-2 focus:ring-ring sm:ml-auto sm:w-36" + /> +
+ )} + + {submitError &&

{submitError}

} + +
+ + + Live {connected ? "connected" : "connecting"} + +
+
+ +
+

Batch readiness

+
+
+ Valid rows + {validRows.length} +
+
+ Fixable rows + {invalidRows.length} +
+
+ Total debit + + {formatTotals(debitByCurrency)} + +
+
+
+
+ + )} + + {!!progress.length && ( +
+
+
+
+

Progress tracker

+

+ {completedCount} of {progress.length} completed +

+
+
+
+
+
+
+ +
+ {progress.map((recipient) => ( +
+
+

{recipient.recipientName}

+

+ {recipient.amount} {recipient.currency} +

+ {recipient.failureReason && ( +

+ {recipient.failureReason} +

+ )} +
+ + {recipient.status} + +

+ {recipient.status === "PENDING" && "Pending"} + {recipient.status === "PROCESSING" && "Processing"} + {recipient.status === "COMPLETED" && "Completed"} + {recipient.status === "FAILED" && "Failed"} + {recipient.status === "CANCELLED" && "Cancelled"} +

+ {recipient.status === "FAILED" && ( + + )} +
+ ))} +
+
+ )} +
+ )}
); } diff --git a/apps/dashboard/src/hooks/useDashboardSocket.ts b/apps/dashboard/src/hooks/useDashboardSocket.ts index 05f9149..b718770 100644 --- a/apps/dashboard/src/hooks/useDashboardSocket.ts +++ b/apps/dashboard/src/hooks/useDashboardSocket.ts @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { io, type Socket } from "socket.io-client"; +import { getToken } from "@/lib/auth"; const SOCKET_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; @@ -10,9 +11,11 @@ export function useDashboardSocket() { const [connected, setConnected] = useState(false); useEffect(() => { + const token = getToken(); const socket = io(SOCKET_URL, { transports: ["websocket"], autoConnect: true, + query: token ? { type: "merchant", token: `Bearer ${token}` } : undefined, }); socket.on("connect", () => setConnected(true)); @@ -25,12 +28,12 @@ export function useDashboardSocket() { }; }, []); - const subscribe = (event: string, callback: (...args: unknown[]) => void) => { + const subscribe = useCallback((event: string, callback: (...args: unknown[]) => void) => { socketRef.current?.on(event, callback); return () => { socketRef.current?.off(event, callback); }; - }; + }, []); return { connected, subscribe, socket: socketRef.current }; }