From 7bb0a852c1dded70e840f0c0c5ea742e32a0fbaa Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Tue, 4 Aug 2026 18:34:58 +0200 Subject: [PATCH 1/3] feat: update balance references to use availableBalance; add batch transfer components and scheduled transfer functionality - Updated balance fetching in AccountInfo, Header, OrganizationWallet, and WelcomeSection components to use availableBalance instead of balance. - Added BatchRecipientRow and BatchResultsList components for handling batch transfers. - Introduced SchedulePicker component for scheduling transfers with recurrence options. - Enhanced TransferPageLayout with buttons for batch and scheduled transfers. - Implemented API methods for creating and managing scheduled transfers and batch transfers. - Updated Tailwind CSS configuration to include additional directories for content scanning. --- .../scheduled-transfers/batches/[id]/page.tsx | 242 ++++++++ app/home/scheduled-transfers/page.tsx | 358 +++++++++++ app/home/transfer/amount/page.tsx | 87 ++- app/home/transfer/batch/page.tsx | 555 ++++++++++++++++++ components/AccountInfo.tsx | 2 +- components/Header.tsx | 2 +- components/OrganizationWallet.tsx | 2 +- components/WelcomeSection.tsx | 4 +- components/transfer/BatchRecipientRow.tsx | 90 +++ components/transfer/BatchResultsList.tsx | 85 +++ components/transfer/SchedulePicker.tsx | 204 +++++++ components/transfer/TransferPageLayout.tsx | 20 +- helpers/api.ts | 163 +++++ tailwind.config.ts | 2 +- 14 files changed, 1805 insertions(+), 11 deletions(-) create mode 100644 app/home/scheduled-transfers/batches/[id]/page.tsx create mode 100644 app/home/scheduled-transfers/page.tsx create mode 100644 app/home/transfer/batch/page.tsx create mode 100644 components/transfer/BatchRecipientRow.tsx create mode 100644 components/transfer/BatchResultsList.tsx create mode 100644 components/transfer/SchedulePicker.tsx diff --git a/app/home/scheduled-transfers/batches/[id]/page.tsx b/app/home/scheduled-transfers/batches/[id]/page.tsx new file mode 100644 index 0000000..084a243 --- /dev/null +++ b/app/home/scheduled-transfers/batches/[id]/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useSidebar } from "@/context/SidebarContext"; +import { useAccent } from "@/hooks/use-accent"; +import { getScheduledBatchById, cancelScheduledBatch } from "@/helpers/api"; +import { Header } from "@/components/Header"; +import Navigation from "@/components/Navigation"; +import { BackButton } from "@/components/shared/BackButton"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Users, CalendarClock, CheckCircle2, XCircle, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "@/hooks/use-toast"; + +interface ScheduledTransferRow { + id: string; + amount: number; + currency: string; + status: string; + scheduledFor: string; + receiverUser?: { id: string; firstName: string; lastName: string } | null; + receiverOrganization?: { id: string; name: string } | null; +} + +interface BatchDetail { + id: string; + status: "completed" | "partial" | "failed"; + recipientCount: number; + successCount: number; + failureCount: number; + totalRequestedAmount: number; + currency: string; + createdAt: string; + scheduledTransfers: ScheduledTransferRow[]; + failures: Array<{ + receiverUserId?: string | null; + receiverOrganizationId?: string | null; + receiverWalletId?: string | null; + amount: number; + reason: string; + }>; +} + +const statusStyles: Record = { + scheduled: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800", + held: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800", + executing: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800", + paused: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800", + completed: "bg-green-100 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800", + failed: "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800", + cancelled: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-darkBg-interactive dark:text-gray-300 dark:border-darkBorder-light", +}; + +const ScheduledBatchDetailPage = () => { + const { isExpanded } = useSidebar(); + const accent = useAccent(); + const params = useParams(); + const batchId = params?.id as string; + + const [batch, setBatch] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isCancelling, setIsCancelling] = useState(false); + + const loadBatch = async () => { + setIsLoading(true); + try { + const res = await getScheduledBatchById(batchId); + if (res.success) setBatch(res.data); + } catch (error) { + console.error("Failed to load scheduled batch:", error); + toast({ title: "Error", description: "Failed to load scheduled batch", variant: "destructive" }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (batchId) loadBatch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [batchId]); + + const handleCancelAll = async () => { + setIsCancelling(true); + try { + const res = await cancelScheduledBatch(batchId); + if (res.success) { + toast({ title: "Cancelled", description: res.message }); + await loadBatch(); + } else { + toast({ title: "Cancel failed", description: res.message, variant: "destructive" }); + } + } catch (error: any) { + toast({ + title: "Cancel failed", + description: error?.response?.data?.message || "Something went wrong", + variant: "destructive", + }); + } finally { + setIsCancelling(false); + } + }; + + const canCancelAny = batch?.scheduledTransfers.some((st) => + ["scheduled", "held", "paused"].includes(st.status) + ); + + return ( +
+ +
+
+
+
+ +
+
+
+ +
+
+ +
+
+

Scheduled Batch

+

+ Multiple recipients, one schedule +

+
+
+
+ + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : !batch ? ( +
+

Scheduled batch not found

+
+ ) : ( + <> + +
+
+
+ + {batch.status} + + + {batch.successCount} of {batch.recipientCount} scheduled + +
+

+ RWF {batch.totalRequestedAmount.toLocaleString()} +

+
+ {canCancelAny && ( + + )} +
+
+ +
+ {batch.scheduledTransfers.map((st) => ( + +
+

+ {st.receiverUser + ? `${st.receiverUser.firstName} ${st.receiverUser.lastName}` + : st.receiverOrganization?.name || "Recipient"} +

+

+ {new Date(st.scheduledFor).toLocaleString()} +

+
+
+ + {st.amount.toLocaleString()} {st.currency} + + + {st.status} + +
+
+ ))} +
+ + {batch.failures.length > 0 && ( +
+

+ Could not be scheduled +

+
+ {batch.failures.map((f, i) => ( +
+ +
+

{f.reason}

+
+ + RWF {f.amount.toLocaleString()} + +
+ ))} +
+
+ )} + + )} +
+
+
+
+ ); +}; + +export default ScheduledBatchDetailPage; diff --git a/app/home/scheduled-transfers/page.tsx b/app/home/scheduled-transfers/page.tsx new file mode 100644 index 0000000..548e68c --- /dev/null +++ b/app/home/scheduled-transfers/page.tsx @@ -0,0 +1,358 @@ +"use client" + +import React, { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { useSidebar } from "@/context/SidebarContext" +import { useAccent } from "@/hooks/use-accent" +import { useAuthToken } from "@/hooks/use-auth-token" +import { getUserIdFromToken } from "@/utils/jwtUtils" +import { + getScheduledTransfers, + cancelScheduledTransfer, + pauseScheduledTransfer, + resumeScheduledTransfer, + skipNextScheduledOccurrence, +} from "@/helpers/api" +import { Header } from "@/components/Header" +import Navigation from "@/components/Navigation" +import { BackButton } from "@/components/shared/BackButton" +import { Card } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + CalendarClock, + Repeat, + Pause, + Play, + SkipForward, + X, + AlertTriangle, + CheckCircle2, + Users, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "@/hooks/use-toast" + +interface ScheduledTransferItem { + id: string + amount: number + currency: string + status: "scheduled" | "held" | "executing" | "completed" | "failed" | "cancelled" | "paused" + scheduledFor: string + recurrenceRule: { frequency: string; interval: number; endDate?: string; maxOccurrences?: number } | null + occurrenceCount: number + lastFailureReason?: string | null + createdByUserId: string + scheduledBatchId?: string | null + receiverUser?: { id: string; firstName: string; lastName: string } | null + receiverOrganization?: { id: string; name: string } | null + senderUser?: { id: string; firstName: string; lastName: string } | null + senderOrganization?: { id: string; name: string } | null +} + +const UPCOMING_STATUSES = "scheduled,held,executing,paused" +const HISTORY_STATUSES = "completed,failed,cancelled" + +const statusStyles: Record = { + scheduled: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800", + held: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800", + executing: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800", + paused: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800", + completed: "bg-green-100 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800", + failed: "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800", + cancelled: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-darkBg-interactive dark:text-gray-300 dark:border-darkBorder-light", +} + +const statusLabels: Record = { + scheduled: "Awaiting funds", + held: "Funds reserved", + executing: "Sending", + paused: "Paused", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", +} + +const getPartyName = (item: ScheduledTransferItem, side: "sender" | "receiver"): string => { + if (side === "sender") { + if (item.senderUser) return `${item.senderUser.firstName} ${item.senderUser.lastName}` + if (item.senderOrganization) return item.senderOrganization.name + return "You" + } + if (item.receiverUser) return `${item.receiverUser.firstName} ${item.receiverUser.lastName}` + if (item.receiverOrganization) return item.receiverOrganization.name + return "Recipient" +} + +const ScheduledTransfersPageInner = () => { + const { isExpanded } = useSidebar() + const accent = useAccent() + const router = useRouter() + const searchParams = useSearchParams() + const { getToken } = useAuthToken() + + const [currentUserId, setCurrentUserId] = useState(null) + const [upcoming, setUpcoming] = useState([]) + const [history, setHistory] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [processingId, setProcessingId] = useState(null) + + const loadData = async () => { + setIsLoading(true) + try { + const [upcomingRes, historyRes] = await Promise.all([ + getScheduledTransfers({ status: UPCOMING_STATUSES, direction: "all" }), + getScheduledTransfers({ status: HISTORY_STATUSES, direction: "all" }), + ]) + if (upcomingRes.success) setUpcoming(upcomingRes.data) + if (historyRes.success) setHistory(historyRes.data) + } catch (error) { + console.error("Failed to load scheduled transfers:", error) + toast({ title: "Error", description: "Failed to load scheduled transfers", variant: "destructive" }) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + const token = getToken() + if (token) setCurrentUserId(getUserIdFromToken(token)) + + loadData() + + if (searchParams.get("created") === "1") { + toast({ title: "Transfer scheduled", description: "Funds have been reserved for your scheduled transfer." }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const runAction = async (id: string, action: () => Promise, successTitle: string) => { + setProcessingId(id) + try { + const res = await action() + if (res.success) { + toast({ title: successTitle }) + await loadData() + } else { + toast({ title: "Action failed", description: res.message, variant: "destructive" }) + } + } catch (error: any) { + toast({ + title: "Action failed", + description: error?.response?.data?.message || "Something went wrong", + variant: "destructive", + }) + } finally { + setProcessingId(null) + } + } + + const ScheduleCard = ({ item }: { item: ScheduledTransferItem }) => { + const isRecurring = !!item.recurrenceRule + const isOwner = item.createdByUserId === currentUserId + const canManage = isOwner && ["scheduled", "held", "paused"].includes(item.status) + + return ( + +
+
+
+

+ {isOwner ? `To ${getPartyName(item, "receiver")}` : `From ${getPartyName(item, "sender")}`} +

+ + {statusLabels[item.status] || item.status} + + {isRecurring && ( + + {item.recurrenceRule?.frequency} + + )} + {item.scheduledBatchId && ( + + )} +
+ +

+ + {new Date(item.scheduledFor).toLocaleString()} +

+ + {item.lastFailureReason && !["completed", "cancelled"].includes(item.status) && ( +

+ {item.lastFailureReason} +

+ )} + + {isRecurring && item.occurrenceCount > 0 && ( +

{item.occurrenceCount} sent so far

+ )} +
+ +
+
+ {item.amount.toLocaleString()} {item.currency} +
+ + {canManage && ( +
+ {item.status === "paused" ? ( + + ) : ( + isRecurring && ( + <> + + + + ) + )} + +
+ )} +
+
+
+ ) + } + + return ( +
+ +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+

Scheduled Transfers

+

+ Money set to send later +

+
+
+ +
+
+ + + + + Upcoming ({upcoming.length}) + + + History ({history.length}) + + + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : ( + <> + + {upcoming.length === 0 ? ( +
+ +

No upcoming scheduled transfers

+
+ ) : ( + upcoming.map((item) => ) + )} +
+ + + {history.length === 0 ? ( +
+ +

No past scheduled transfers yet

+
+ ) : ( + history.map((item) => ) + )} +
+ + )} + +
+
+
+
+ ) +} + +export default function ScheduledTransfersPage() { + return ( + + + + ) +} diff --git a/app/home/transfer/amount/page.tsx b/app/home/transfer/amount/page.tsx index e34acc6..804fe2d 100644 --- a/app/home/transfer/amount/page.tsx +++ b/app/home/transfer/amount/page.tsx @@ -11,6 +11,7 @@ import { getUserWallet, getWalletRestrictions, transferMoney, + scheduleTransfer, getPaymentRequestById, } from "@/helpers/api"; import { cn } from "@/lib/utils"; @@ -19,6 +20,7 @@ import { Header } from "@/components/Header"; import RecipientHeader from "@/components/transfer/RecipientHeader"; import AmountInput from "@/components/transfer/AmountInput"; import CategorySelector from "@/components/transfer/CategorySelector"; +import SchedulePicker, { ScheduleState, defaultScheduleState } from "@/components/transfer/SchedulePicker"; import PinEntry from "@/components/transfer/PinEntry"; import RequestContextBanner from "@/components/transfer/RequestContextBanner"; import TransferSummaryCard from "@/components/transfer/TransferSummaryCard"; @@ -26,7 +28,7 @@ import StepIndicator from "@/components/transfer/StepIndicator"; import RequestLoadingSkeleton from "@/components/transfer/RequestLoadingSkeleton"; import { PinSetupModal } from "@/components/PinSetupModal"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Loader2, AlertCircle, ScanLine } from "lucide-react"; +import { Loader2, AlertCircle, ScanLine, CalendarClock } from "lucide-react"; import { isTokenExpired, getUserIdFromToken } from "@/utils/jwtUtils"; import { useAccent } from "@/hooks/use-accent"; @@ -94,6 +96,7 @@ const AmountPageInner = () => { const [categories, setCategories] = useState([]); const [selectedCategory, setSelectedCategory] = useState(null); const [applyConstraints, setApplyConstraints] = useState(false); + const [schedule, setSchedule] = useState(defaultScheduleState); const [isOrganization, setIsOrganization] = useState(false); const [organizationCategory, setOrganizationCategory] = useState(null); @@ -187,7 +190,7 @@ const AmountPageInner = () => { const balRes = await getEntityBalance(userId, "user").catch(() => getEntityBalance(userId, "organization") ); - if (balRes.success) setCurrentBalance(Number(balRes.data.balance)); + if (balRes.success) setCurrentBalance(Number(balRes.data.availableBalance)); const catRes: any = await getTransactionCategories(); if (catRes.data?.success) setCategories(catRes.data.data); @@ -223,6 +226,28 @@ const AmountPageInner = () => { setError("Insufficient balance"); return; } + + if (schedule.enabled && !requestId) { + if (!schedule.date || !schedule.time) { + setError("Please choose a date and time for this transfer"); + return; + } + const scheduledDateTime = new Date(`${schedule.date}T${schedule.time}`); + const minAllowed = new Date(Date.now() + 5 * 60000); + if (isNaN(scheduledDateTime.getTime()) || scheduledDateTime < minAllowed) { + setError("Scheduled time must be at least 5 minutes from now"); + return; + } + if ( + schedule.recurrence.frequency !== "none" && + schedule.recurrence.endMode === "date" && + !schedule.recurrence.endDate + ) { + setError("Please choose an end date for the recurring transfer"); + return; + } + } + setError(""); setStep(2); }; @@ -288,6 +313,38 @@ const AmountPageInner = () => { params.receiverUserId = recipient?.id; } + if (schedule.enabled && !requestId) { + const scheduledDateTime = new Date(`${schedule.date}T${schedule.time}`); + delete params.paymentRequestId; + + let recurrence: any = undefined; + if (schedule.recurrence.frequency !== "none") { + recurrence = { + frequency: schedule.recurrence.frequency, + interval: schedule.recurrence.interval || 1, + }; + if (schedule.recurrence.endMode === "date" && schedule.recurrence.endDate) { + recurrence.endDate = new Date(schedule.recurrence.endDate).toISOString(); + } else if (schedule.recurrence.endMode === "count") { + recurrence.maxOccurrences = schedule.recurrence.maxOccurrences; + } + } + + const result = await scheduleTransfer({ + ...params, + scheduledFor: scheduledDateTime.toISOString(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + recurrence, + }); + + if (result.success) { + router.push("/home/scheduled-transfers?created=1"); + } else { + setError(result.message || "Failed to schedule transfer"); + } + return; + } + const result = await transferMoney(params); if (result.success) { @@ -459,6 +516,15 @@ const AmountPageInner = () => { /> )} + {/* Scheduling — hidden for request flows, which need immediate fulfillment */} + {!isQRFlow && ( + + )} + {/* Category selector — hidden for request flows */} {!isQRFlow && ( { currency={requestMeta?.currency || "RWF"} /> + {schedule.enabled && !requestId && schedule.date && schedule.time && ( +
+ +

+ + Scheduled + {" "} + for {new Date(`${schedule.date}T${schedule.time}`).toLocaleString()} + {schedule.recurrence.frequency !== "none" && + `, repeating ${schedule.recurrence.frequency}`} + . Funds will be reserved from your balance now. +

+
+ )} + { > {loading ? ( + ) : schedule.enabled && !requestId ? ( + "Confirm Schedule" ) : ( "Confirm Transfer" )} diff --git a/app/home/transfer/batch/page.tsx b/app/home/transfer/batch/page.tsx new file mode 100644 index 0000000..a0e9215 --- /dev/null +++ b/app/home/transfer/batch/page.tsx @@ -0,0 +1,555 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Loader2, AlertCircle, Users, CalendarClock } from "lucide-react"; +import { useSidebar } from "@/context/SidebarContext"; +import { useAuthToken } from "@/hooks/use-auth-token"; +import { useAccent } from "@/hooks/use-accent"; +import { useGetAcceptedContactsQuery } from "@/states/contactSlice"; +import { cn } from "@/lib/utils"; +import Navigation from "@/components/Navigation"; +import { Header } from "@/components/Header"; +import StepIndicator from "@/components/transfer/StepIndicator"; +import PinEntry from "@/components/transfer/PinEntry"; +import SchedulePicker, { ScheduleState, defaultScheduleState } from "@/components/transfer/SchedulePicker"; +import { PinSetupModal } from "@/components/PinSetupModal"; +import BatchRecipientRow, { BatchContact } from "@/components/transfer/BatchRecipientRow"; +import BatchResultsList, { BatchResultEntry } from "@/components/transfer/BatchResultsList"; +import { + checkUserPinStatus, + createBatchTransfer, + createScheduledBatchTransfer, + getEntityBalance, + getUserWallet, +} from "@/helpers/api"; +import { isTokenExpired, getUserIdFromToken } from "@/utils/jwtUtils"; + +const getApiErrorMessage = (err: any): string => + err?.response?.data?.message || err?.message || "An error occurred while sending the batch"; + +const BatchTransferPage = () => { + const router = useRouter(); + const { isExpanded } = useSidebar(); + const { getToken } = useAuthToken(); + const accent = useAccent(); + + const [step, setStep] = useState<1 | 2 | 3>(1); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [amounts, setAmounts] = useState>({}); + const [splitTotal, setSplitTotal] = useState(""); + const [schedule, setSchedule] = useState(defaultScheduleState); + + const [pin, setPin] = useState(""); + const [showPin, setShowPin] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [showPinSetup, setShowPinSetup] = useState(false); + const [hasPinSet, setHasPinSet] = useState(null); + + const [currentBalance, setCurrentBalance] = useState(null); + const [idempotencyKey, setIdempotencyKey] = useState(""); + + const [results, setResults] = useState([]); + const [resultSummary, setResultSummary] = useState<{ + successCount: number; + failureCount: number; + totalSent: number; + scheduled: boolean; + scheduledFor?: string; + batchId?: string; + } | null>(null); + + const token = getToken(); + const { data: contactsData, isLoading: isContactsLoading } = useGetAcceptedContactsQuery(token || "", { + skip: !token, + }); + + const contacts: BatchContact[] = useMemo(() => { + if (!contactsData?.contacts) return []; + return contactsData.contacts.map((contact: any) => ({ + id: contact.otherUser.id, + name: `${contact.otherUser.firstName} ${contact.otherUser.lastName}`, + phone: contact.otherUser.phone || "", + avatar: contact.otherUser.profile?.profileImage || null, + })); + }, [contactsData]); + + const contactsById = useMemo(() => { + const map: Record = {}; + contacts.forEach((c) => (map[c.id] = c)); + return map; + }, [contacts]); + + const filteredContacts = contacts.filter( + (c) => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.phone.includes(searchQuery) + ); + + useEffect(() => { + const checkPin = async () => { + const authToken = getToken(); + if (!authToken) return; + try { + const response: any = await checkUserPinStatus(); + const pinStatus = response?.data?.data?.hasPinSet || false; + setHasPinSet(pinStatus); + if (!pinStatus) setShowPinSetup(true); + } catch { + // silent + } + }; + checkPin(); + + const fetchBalance = async () => { + const authToken = getToken(); + if (!authToken || isTokenExpired(authToken)) return; + const userId = getUserIdFromToken(authToken); + if (!userId) return; + try { + const res = await getEntityBalance(userId, "user").catch(() => getEntityBalance(userId, "organization")); + if (res.success) setCurrentBalance(Number(res.data.availableBalance)); + } catch { + // silent + } + }; + fetchBalance(); + }, [getToken]); + + const toggleContact = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + setAmounts((a) => { + const copy = { ...a }; + delete copy[id]; + return copy; + }); + } else { + next.add(id); + } + return next; + }); + setError(""); + }; + + const setAmountFor = (id: string, value: string) => { + setAmounts((prev) => ({ ...prev, [id]: value })); + setError(""); + }; + + const selectedContacts = contacts.filter((c) => selectedIds.has(c.id)); + const total = selectedContacts.reduce((sum, c) => sum + (parseFloat(amounts[c.id]) || 0), 0); + + const handleSplitEvenly = () => { + const totalNum = parseFloat(splitTotal); + if (!totalNum || totalNum <= 0 || selectedContacts.length === 0) return; + const share = (totalNum / selectedContacts.length).toFixed(2); + const next: Record = { ...amounts }; + selectedContacts.forEach((c) => (next[c.id] = share)); + setAmounts(next); + }; + + const handleContinue = () => { + if (selectedContacts.length === 0) { + setError("Select at least one person to send to"); + return; + } + for (const c of selectedContacts) { + const amt = parseFloat(amounts[c.id]); + if (!amt || amt <= 0) { + setError(`Enter an amount for ${c.name}`); + return; + } + } + if (currentBalance !== null && total > currentBalance) { + setError("Total exceeds your available balance"); + return; + } + + if (schedule.enabled) { + if (!schedule.date || !schedule.time) { + setError("Please choose a date and time for this batch"); + return; + } + const scheduledDateTime = new Date(`${schedule.date}T${schedule.time}`); + const minAllowed = new Date(Date.now() + 5 * 60000); + if (isNaN(scheduledDateTime.getTime()) || scheduledDateTime < minAllowed) { + setError("Scheduled time must be at least 5 minutes from now"); + return; + } + if ( + schedule.recurrence.frequency !== "none" && + schedule.recurrence.endMode === "date" && + !schedule.recurrence.endDate + ) { + setError("Please choose an end date for the recurring batch"); + return; + } + } + + setError(""); + setIdempotencyKey(`batch-${Date.now()}-${Math.random().toString(36).slice(2)}`); + setStep(2); + }; + + const handleConfirm = async () => { + if (pin.length < 4) { + setError("Please enter your 4-digit PIN"); + return; + } + + setLoading(true); + setError(""); + + try { + const authToken = getToken(); + const rawUserId = getUserIdFromToken(authToken || ""); + if (!rawUserId) { + throw new Error("Unable to identify sender"); + } + + let senderUserId: string | undefined; + let senderOrganizationId: string | undefined; + try { + await getUserWallet(rawUserId); + senderUserId = rawUserId; + } catch { + senderOrganizationId = rawUserId; + } + + const recipients = selectedContacts.map((c) => ({ + receiverUserId: c.id, + amount: parseFloat(amounts[c.id]), + })); + + if (schedule.enabled) { + const scheduledDateTime = new Date(`${schedule.date}T${schedule.time}`); + + let recurrence: any = undefined; + if (schedule.recurrence.frequency !== "none") { + recurrence = { + frequency: schedule.recurrence.frequency, + interval: schedule.recurrence.interval || 1, + }; + if (schedule.recurrence.endMode === "date" && schedule.recurrence.endDate) { + recurrence.endDate = new Date(schedule.recurrence.endDate).toISOString(); + } else if (schedule.recurrence.endMode === "count") { + recurrence.maxOccurrences = schedule.recurrence.maxOccurrences; + } + } + + const result = await createScheduledBatchTransfer({ + senderUserId, + senderOrganizationId, + recipients, + pin, + description: "Batch transfer", + scheduledFor: scheduledDateTime.toISOString(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + recurrence, + }); + + if (result.success) { + setResults(result.data.results); + setResultSummary({ + successCount: result.data.successCount, + failureCount: result.data.failureCount, + totalSent: result.data.totalRequestedAmount, + scheduled: true, + scheduledFor: result.data.scheduledFor, + batchId: result.data.batchId, + }); + setStep(3); + } else { + setError(result.message || "Failed to schedule batch"); + } + return; + } + + const result = await createBatchTransfer({ + senderUserId, + senderOrganizationId, + recipients, + pin, + description: "Batch transfer", + idempotencyKey, + }); + + if (result.success) { + setResults(result.data.results); + setResultSummary({ + successCount: result.data.successCount, + failureCount: result.data.failureCount, + totalSent: result.data.totalSentAmount, + scheduled: false, + }); + setStep(3); + } else { + setError(result.message || "Batch transfer failed"); + } + } catch (err: any) { + setError(getApiErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+
+ +
+
+ +

+ Send to Multiple People +

+
+ + + + {step === 1 && ( +
+ {currentBalance !== null && ( +
+ Your balance + currentBalance + ? "text-red-500 dark:text-red-400" + : "text-gray-900 dark:text-white" + )} + > + RWF {currentBalance.toLocaleString()} + +
+ )} + + + + {selectedContacts.length > 1 && ( +
+

+ Split a total evenly (optional) +

+
+ setSplitTotal(e.target.value)} + className="flex-1 px-4 py-2.5 bg-gray-50 dark:bg-darkBg-main rounded-xl border border-gray-200 dark:border-darkBorder-light text-gray-900 dark:text-white text-sm" + /> + +
+
+ )} + +
+ setSearchQuery(e.target.value)} + className="w-full px-4 py-3 bg-gray-50 dark:bg-darkBg-main rounded-xl border border-gray-200 dark:border-darkBorder-light text-gray-900 dark:text-white placeholder-gray-400" + /> +
+ +
+ {isContactsLoading ? ( +
Loading contacts...
+ ) : filteredContacts.length > 0 ? ( + filteredContacts.map((contact) => ( + toggleContact(contact.id)} + onAmountChange={(value) => setAmountFor(contact.id, value)} + /> + )) + ) : ( +
No contacts found.
+ )} +
+ + {selectedContacts.length > 0 && ( +
+
+ + {selectedContacts.length} {selectedContacts.length === 1 ? "person" : "people"} selected +
+ + RWF {total.toLocaleString()} + +
+ )} + + {error && ( +
+ {error} +
+ )} + + +
+ )} + + {step === 2 && ( +
+
+

+ Sending to {selectedContacts.length} {selectedContacts.length === 1 ? "person" : "people"} +

+
+ Total + + RWF {total.toLocaleString()} + +
+
+ + {schedule.enabled && schedule.date && schedule.time && ( +
+ +

+ Scheduled{" "} + for {new Date(`${schedule.date}T${schedule.time}`).toLocaleString()} + {schedule.recurrence.frequency !== "none" && `, repeating ${schedule.recurrence.frequency}`}. + Funds will be reserved from your balance now. +

+
+ )} + + + + + + +
+ )} + + {step === 3 && resultSummary && ( +
+
+ {resultSummary.scheduled ? ( + resultSummary.failureCount === 0 ? ( +

+ Scheduled RWF {resultSummary.totalSent.toLocaleString()} for all {resultSummary.successCount}{" "} + {resultSummary.successCount === 1 ? "person" : "people"} + {resultSummary.scheduledFor && ` on ${new Date(resultSummary.scheduledFor).toLocaleString()}`} +

+ ) : ( +

+ Scheduled for {resultSummary.successCount} of{" "} + {resultSummary.successCount + resultSummary.failureCount} people —{" "} + {resultSummary.failureCount} could not be scheduled +

+ ) + ) : resultSummary.failureCount === 0 ? ( +

+ Sent RWF {resultSummary.totalSent.toLocaleString()} to all {resultSummary.successCount}{" "} + {resultSummary.successCount === 1 ? "person" : "people"} +

+ ) : ( +

+ Sent to {resultSummary.successCount} of {resultSummary.successCount + resultSummary.failureCount}{" "} + people — {resultSummary.failureCount} failed +

+ )} +
+ + + + +
+ )} + + {!isContactsLoading && contacts.length === 0 && step === 1 && ( +
+ + Add some contacts first from the main transfer page before sending to multiple people. +
+ )} +
+
+
+ + setHasPinSet(true)} /> +
+ ); +}; + +export default BatchTransferPage; diff --git a/components/AccountInfo.tsx b/components/AccountInfo.tsx index ee7d560..d8705fc 100644 --- a/components/AccountInfo.tsx +++ b/components/AccountInfo.tsx @@ -45,7 +45,7 @@ const AccountInfo: React.FC = ({ userId }) => { } if (response.success && response.data) { - setBalance(Number(response.data.balance)); + setBalance(Number(response.data.availableBalance)); } else { setBalanceError('Invalid balance data received'); } diff --git a/components/Header.tsx b/components/Header.tsx index b735768..aaa3986 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -120,7 +120,7 @@ export const Header = ({ showBackButton = false }: HeaderProps) => { } if (response.success && response.data) { - setBalance(Number(response.data.balance)); + setBalance(Number(response.data.availableBalance)); } else { setBalanceError('Invalid balance data received'); } diff --git a/components/OrganizationWallet.tsx b/components/OrganizationWallet.tsx index 649538c..f80c233 100644 --- a/components/OrganizationWallet.tsx +++ b/components/OrganizationWallet.tsx @@ -38,7 +38,7 @@ export const OrganizationWallet: React.FC = ({ // Fetch balance const balanceResponse = await getOrganizationBalance(organizationId); if (balanceResponse.success) { - setBalance(balanceResponse.data.balance); + setBalance(balanceResponse.data.availableBalance); } // Fetch restrictions if wallet exists diff --git a/components/WelcomeSection.tsx b/components/WelcomeSection.tsx index 0ee9798..3ad5ab2 100644 --- a/components/WelcomeSection.tsx +++ b/components/WelcomeSection.tsx @@ -50,7 +50,7 @@ export const WelcomeSection: React.FC = ({ userId }) => { } if (response.success && response.data) { - setBalance(Number(response.data.balance)); + setBalance(Number(response.data.availableBalance)); } else { setBalanceError('Unable to fetch balance'); } @@ -92,7 +92,7 @@ export const WelcomeSection: React.FC = ({ userId }) => {

-

Total Balance

+

Available Balance

{balanceLoading diff --git a/components/transfer/BatchRecipientRow.tsx b/components/transfer/BatchRecipientRow.tsx new file mode 100644 index 0000000..0ce731b --- /dev/null +++ b/components/transfer/BatchRecipientRow.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React from "react"; +import { Check } from "lucide-react"; +import { UserAvatar } from "@/components/UserAvatar"; +import { cn } from "@/lib/utils"; + +export interface BatchContact { + id: string; + name: string; + phone: string; + avatar: string | null; +} + +interface BatchRecipientRowProps { + contact: BatchContact; + isSelected: boolean; + amount: string; + onToggle: () => void; + onAmountChange: (value: string) => void; +} + +const BatchRecipientRow = ({ contact, isSelected, amount, onToggle, onAmountChange }: BatchRecipientRowProps) => { + const nameParts = contact.name.split(" "); + const firstName = nameParts[0] || ""; + const lastName = nameParts.slice(1).join(" ") || ""; + + return ( +
+ + + {isSelected && ( +
+
+ + RWF + + onAmountChange(e.target.value)} + onClick={(e) => e.stopPropagation()} + className="w-full pl-14 pr-4 py-2.5 bg-white dark:bg-darkBg-main rounded-xl border border-gray-200 dark:border-darkBorder-light focus:border-brand-green dark:focus:border-brand-gold focus:ring-2 focus:ring-green-100 dark:focus:ring-brand-gold/10 transition-all text-gray-900 dark:text-white font-semibold" + /> +
+
+ )} +
+ ); +}; + +export default BatchRecipientRow; diff --git a/components/transfer/BatchResultsList.tsx b/components/transfer/BatchResultsList.tsx new file mode 100644 index 0000000..f8110b1 --- /dev/null +++ b/components/transfer/BatchResultsList.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React from "react"; +import { CheckCircle2, XCircle } from "lucide-react"; +import { UserAvatar } from "@/components/UserAvatar"; +import { cn } from "@/lib/utils"; +import { BatchContact } from "./BatchRecipientRow"; + +export interface BatchResultEntry { + receiverUserId?: string | null; + receiverOrganizationId?: string | null; + receiverWalletId?: string | null; + amount: number; + status: "success" | "failed"; + transactionId?: string; + reason?: string; +} + +interface BatchResultsListProps { + results: BatchResultEntry[]; + contactsById: Record; +} + +const BatchResultsList = ({ results, contactsById }: BatchResultsListProps) => { + return ( +
+ {results.map((result, index) => { + const contact = result.receiverUserId ? contactsById[result.receiverUserId] : undefined; + const nameParts = (contact?.name || "Recipient").split(" "); + const firstName = nameParts[0] || ""; + const lastName = nameParts.slice(1).join(" ") || ""; + + return ( +
+ + +
+

+ {contact?.name || "Recipient"} +

+ {result.status === "failed" && ( +

{result.reason}

+ )} +
+ +
+

+ RWF {result.amount.toLocaleString()} +

+
+ + {result.status === "success" ? ( + + ) : ( + + )} +
+ ); + })} +
+ ); +}; + +export default BatchResultsList; diff --git a/components/transfer/SchedulePicker.tsx b/components/transfer/SchedulePicker.tsx new file mode 100644 index 0000000..0dc26a8 --- /dev/null +++ b/components/transfer/SchedulePicker.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { Calendar, Clock, Repeat, Zap } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface RecurrenceState { + frequency: "none" | "daily" | "weekly" | "monthly" | "yearly"; + interval: number; + endMode: "never" | "date" | "count"; + endDate: string; + maxOccurrences: number; +} + +export interface ScheduleState { + enabled: boolean; + date: string; + time: string; + recurrence: RecurrenceState; +} + +export const defaultScheduleState: ScheduleState = { + enabled: false, + date: "", + time: "", + recurrence: { + frequency: "none", + interval: 1, + endMode: "never", + endDate: "", + maxOccurrences: 5, + }, +}; + +interface SchedulePickerProps { + schedule: ScheduleState; + onChange: (schedule: ScheduleState) => void; + minDate: string; +} + +const frequencies: { value: RecurrenceState["frequency"]; label: string }[] = [ + { value: "none", label: "Never" }, + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + { value: "yearly", label: "Yearly" }, +]; + +const endOptions: { value: RecurrenceState["endMode"]; label: string }[] = [ + { value: "never", label: "Never" }, + { value: "date", label: "On date" }, + { value: "count", label: "After N" }, +]; + +// Native date/time/number inputs render their own browser chrome (calendar icon, spinner +// arrows) independent of our Tailwind classes - without an explicit color-scheme, that +// chrome stays light-themed (e.g. a near-invisible dark icon on a dark background). +const fieldClass = + "w-full px-3 py-2.5 rounded-xl border border-gray-200 dark:border-darkBorder-light bg-gray-50 dark:bg-darkBg-main text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-green/30 dark:focus:ring-brand-gold/30 [color-scheme:light] dark:[color-scheme:dark]"; + +const SchedulePicker = ({ schedule, onChange, minDate }: SchedulePickerProps) => { + const update = (patch: Partial) => onChange({ ...schedule, ...patch }); + const updateRecurrence = (patch: Partial) => + onChange({ ...schedule, recurrence: { ...schedule.recurrence, ...patch } }); + + return ( +
+
+ + +
+ + {schedule.enabled && ( +
+
+
+ + update({ date: e.target.value })} + className={fieldClass} + /> +
+
+ + update({ time: e.target.value })} + className={fieldClass} + /> +
+
+ +
+ +
+ {frequencies.map((f) => ( + + ))} +
+
+ + {schedule.recurrence.frequency !== "none" && ( +
+
+ +
+ {endOptions.map((opt) => ( + + ))} +
+
+ + {schedule.recurrence.endMode === "date" && ( + updateRecurrence({ endDate: e.target.value })} + className={cn(fieldClass, "bg-white dark:bg-darkBg-card")} + /> + )} + + {schedule.recurrence.endMode === "count" && ( + + updateRecurrence({ maxOccurrences: Math.max(1, parseInt(e.target.value) || 1) }) + } + placeholder="Number of occurrences" + className={cn(fieldClass, "bg-white dark:bg-darkBg-card")} + /> + )} +
+ )} + +

+ Funds are reserved from your balance immediately so this transfer is guaranteed to send on time. +

+
+ )} +
+ ); +}; + +export default SchedulePicker; diff --git a/components/transfer/TransferPageLayout.tsx b/components/transfer/TransferPageLayout.tsx index 775329c..e041626 100644 --- a/components/transfer/TransferPageLayout.tsx +++ b/components/transfer/TransferPageLayout.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; -import { ArrowLeft, Search } from "lucide-react"; +import { ArrowLeft, CalendarClock, Search, Users } from "lucide-react"; import { useRouter } from "next/navigation"; import { useAuthToken } from "@/hooks/use-auth-token"; import { useGetAcceptedContactsQuery, useSendContactInvitationByPublicIdMutation } from "@/states/contactSlice"; @@ -74,7 +74,7 @@ const TransferPageLayout = () => { } if (response.success && response.data) { - setBalance(Number(response.data.balance)); + setBalance(Number(response.data.availableBalance)); } } catch (err) { console.error("Could not fetch balance"); @@ -357,7 +357,21 @@ const TransferPageLayout = () => { > -

Send Money

+

Send Money

+ +

{/* Balance Card */} diff --git a/helpers/api.ts b/helpers/api.ts index b293e33..7ab2105 100644 --- a/helpers/api.ts +++ b/helpers/api.ts @@ -25,6 +25,7 @@ const getAuthHeaders = () => { const apiGet = (url: string) => axios.get(`${baseUrl}${url}`, { headers: getAuthHeaders() }); const apiPost = (url: string, data: any) => axios.post(`${baseUrl}${url}`, data, { headers: getAuthHeaders() }); const apiPut = (url: string, data: any) => axios.put(`${baseUrl}${url}`, data, { headers: getAuthHeaders() }); +const apiPatch = (url: string, data: any) => axios.patch(`${baseUrl}${url}`, data, { headers: getAuthHeaders() }); const apiDelete = (url: string) => axios.delete(`${baseUrl}${url}`, { headers: getAuthHeaders() }); // Helper for FormData requests (no Content-Type header, let browser set it with boundary) @@ -156,6 +157,168 @@ const createTransferKey = (params: any) => { return `${senderId}-${receiverId}-${amount}`; }; +/* ---------- Scheduled transfers ---------- */ + +export interface ScheduleRecurrenceInput { + frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'; + interval?: number; + endDate?: string; + maxOccurrences?: number; +} + +export interface ScheduleTransferParams { + senderUserId?: string; + senderOrganizationId?: string; + senderSubActionId?: string; + receiverUserId?: string; + receiverOrganizationId?: string; + receiverWalletId?: string; + amount: number; + description?: string; + categoryId?: string; + type?: string; + applyConstraints?: boolean; + pin: string; + scheduledFor: string; + timezone?: string; + recurrence?: ScheduleRecurrenceInput; +} + +export const scheduleTransfer = async (params: ScheduleTransferParams) => { + validateTransferParams(params); + const res = await apiPost('/scheduled-transfers', params); + return res.data; +}; + +export const getScheduledTransfers = async (params?: { + status?: string; + direction?: 'outgoing' | 'incoming' | 'all'; + limit?: number; + offset?: number; +}) => { + const query = new URLSearchParams(); + if (params?.status) query.set('status', params.status); + if (params?.direction) query.set('direction', params.direction); + if (params?.limit) query.set('limit', String(params.limit)); + if (params?.offset) query.set('offset', String(params.offset)); + const qs = query.toString(); + const res = await apiGet(`/scheduled-transfers${qs ? `?${qs}` : ''}`); + return res.data; +}; + +export const getScheduledTransferById = async (id: string) => { + const res = await apiGet(`/scheduled-transfers/${id}`); + return res.data; +}; + +export const updateScheduledTransfer = async (id: string, params: { + amount?: number; + scheduledFor?: string; + description?: string; + categoryId?: string; + applyConstraints?: boolean; + recurrence?: ScheduleRecurrenceInput | null; + pin?: string; +}) => { + const res = await apiPatch(`/scheduled-transfers/${id}`, params); + return res.data; +}; + +export const cancelScheduledTransfer = async (id: string) => { + const res = await apiPost(`/scheduled-transfers/${id}/cancel`, {}); + return res.data; +}; + +export const pauseScheduledTransfer = async (id: string) => { + const res = await apiPost(`/scheduled-transfers/${id}/pause`, {}); + return res.data; +}; + +export const resumeScheduledTransfer = async (id: string) => { + const res = await apiPost(`/scheduled-transfers/${id}/resume`, {}); + return res.data; +}; + +export const skipNextScheduledOccurrence = async (id: string) => { + const res = await apiPost(`/scheduled-transfers/${id}/skip-next`, {}); + return res.data; +}; + +export interface BatchRecipientInput { + receiverUserId?: string; + receiverOrganizationId?: string; + receiverWalletId?: string; + amount: number; + description?: string; +} + +export const createBatchTransfer = async (params: { + senderUserId?: string; + senderOrganizationId?: string; + senderSubActionId?: string; + recipients: BatchRecipientInput[]; + description?: string; + categoryId?: string; + type?: string; + applyConstraints?: boolean; + pin: string; + idempotencyKey?: string; +}) => { + const res = await apiPost('/transactions/batch-transfer', params); + return res.data; +}; + +export const getBatchTransfers = async (params?: { page?: number; limit?: number }) => { + const query = new URLSearchParams(); + if (params?.page) query.set('page', String(params.page)); + if (params?.limit) query.set('limit', String(params.limit)); + const qs = query.toString(); + const res = await apiGet(`/transactions/batches${qs ? `?${qs}` : ''}`); + return res.data; +}; + +export const getBatchTransferById = async (id: string) => { + const res = await apiGet(`/transactions/batch/${id}`); + return res.data; +}; + +export const createScheduledBatchTransfer = async (params: { + senderUserId?: string; + senderOrganizationId?: string; + senderSubActionId?: string; + recipients: BatchRecipientInput[]; + description?: string; + categoryId?: string; + type?: string; + applyConstraints?: boolean; + pin: string; + scheduledFor: string; + timezone: string; + recurrence?: ScheduleRecurrenceInput | null; +}) => { + const res = await apiPost('/scheduled-transfers/batch', params); + return res.data; +}; + +export const getScheduledBatches = async (params?: { limit?: number; offset?: number }) => { + const query = new URLSearchParams(); + if (params?.limit) query.set('limit', String(params.limit)); + if (params?.offset) query.set('offset', String(params.offset)); + const qs = query.toString(); + const res = await apiGet(`/scheduled-transfers/batches${qs ? `?${qs}` : ''}`); + return res.data; +}; + +export const getScheduledBatchById = async (id: string) => { + const res = await apiGet(`/scheduled-transfers/batch/${id}`); + return res.data; +}; + +export const cancelScheduledBatch = async (id: string) => { + const res = await apiPost(`/scheduled-transfers/batch/${id}/cancel`, {}); + return res.data; +}; + export const getAllUsers = async () => { const res = await axios.get(`${baseUrl}/users`); return res.data; diff --git a/tailwind.config.ts b/tailwind.config.ts index 0e23dee..15cc54f 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,7 +2,7 @@ import type { Config } from 'tailwindcss' const config: Config = { darkMode: ["class"], - content: ["./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}", "*.{js,ts,jsx,tsx,mdx}"], + content: ["./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}", "./hooks/**/*.{js,ts,jsx,tsx}", "./context/**/*.{js,ts,jsx,tsx}", "./helpers/**/*.{js,ts,jsx,tsx}", "./lib/**/*.{js,ts,jsx,tsx}", "./services/**/*.{js,ts,jsx,tsx}", "./states/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}", "*.{js,ts,jsx,tsx,mdx}"], theme: { container: { center: true, From 3ecee0a783df87a391a55f122a327ce07afaecc3 Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Thu, 13 Aug 2026 16:40:08 +0200 Subject: [PATCH 2/3] feat: implement escrow functionality with escrow message card, escrow slice, and send money modal updates --- components/chat/escrow-message-card.tsx | 254 ++++++++++++++++++++++++ components/chat/message-item.tsx | 28 ++- components/chat/send-money-modal.tsx | 202 +++++++++++++++---- states/apiSlice.ts | 2 +- states/escrowSlice.ts | 79 ++++++++ types/chat.types.ts | 2 +- 6 files changed, 526 insertions(+), 41 deletions(-) create mode 100644 components/chat/escrow-message-card.tsx create mode 100644 states/escrowSlice.ts diff --git a/components/chat/escrow-message-card.tsx b/components/chat/escrow-message-card.tsx new file mode 100644 index 0000000..07f6404 --- /dev/null +++ b/components/chat/escrow-message-card.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React from "react"; +import { Lock, CheckCircle, Undo2, AlertTriangle, Clock, ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getCurrentUserId } from "@/utils/tokenUtils"; +import { + useGetEscrowByIdQuery, + useReleaseEscrowMutation, + useRefundEscrowMutation, + useDisputeEscrowMutation, +} from "@/states/escrowSlice"; +import { toast } from "@/hooks/use-toast"; + +interface EscrowMessageData { + type: "escrow"; + escrowId: string; + amount: number; + currency: string; + payerName: string; + payeeName: string; + description?: string; + releaseMode: "manual" | "auto_timeout"; + autoReleaseAt?: string | null; + timestamp: string; +} + +interface EscrowMessageCardProps { + data: EscrowMessageData; + isMe: boolean; + chatId?: string; +} + +function fmt(amount: number, currency = "RWF") { + return new Intl.NumberFormat("en-RW", { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +function fmtTime(ts: string) { + return new Date(ts).toLocaleString("en-US", { + month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + }); +} + +export const EscrowMessageCard: React.FC = ({ data, isMe, chatId }) => { + const currentUserId = getCurrentUserId(); + + // Polls while mounted so a release/refund/dispute made by the other party (or the + // auto-release cron) shows up here without needing a page refresh. + const { data: escrowResp } = useGetEscrowByIdQuery({ escrowId: data.escrowId }, { pollingInterval: 15000 }); + const escrow = escrowResp?.data; + + const [releaseEscrow, { isLoading: releasing }] = useReleaseEscrowMutation(); + const [refundEscrow, { isLoading: refunding }] = useRefundEscrowMutation(); + const [disputeEscrow, { isLoading: disputing }] = useDisputeEscrowMutation(); + + const [showDisputeForm, setShowDisputeForm] = React.useState(false); + const [disputeReason, setDisputeReason] = React.useState(""); + + const status: string = escrow?.status || "held"; + const isPayer = Boolean(escrow?.payerUserId && currentUserId && escrow.payerUserId === currentUserId); + const isPayee = Boolean(escrow?.payeeUserId && currentUserId && escrow.payeeUserId === currentUserId); + const isHeld = status === "held"; + + const handleRelease = async () => { + try { + await releaseEscrow({ escrowId: data.escrowId, chatId }).unwrap(); + toast({ title: "Funds released", description: `${fmt(data.amount, data.currency)} sent to ${data.payeeName}` }); + } catch (err: any) { + toast({ title: "Could not release", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + + const handleRefund = async () => { + try { + await refundEscrow({ escrowId: data.escrowId, chatId }).unwrap(); + toast({ title: "Escrow cancelled", description: `${fmt(data.amount, data.currency)} returned to your balance` }); + } catch (err: any) { + toast({ title: "Could not cancel", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + + const handleDispute = async () => { + if (!disputeReason.trim()) { + toast({ title: "Reason required", description: "Please explain the issue", variant: "destructive" }); + return; + } + try { + await disputeEscrow({ escrowId: data.escrowId, chatId, reason: disputeReason }).unwrap(); + toast({ title: "Dispute raised", description: "An admin will review this escrow" }); + setShowDisputeForm(false); + } catch (err: any) { + toast({ title: "Could not raise dispute", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + + const statusPill = () => { + if (status === "released") return ( + + Released + + ); + if (status === "refunded") return ( + + Refunded + + ); + if (status === "disputed") return ( + + Disputed + + ); + if (status === "cancelled" || status === "expired") return ( + + {status === "expired" ? "Expired" : "Cancelled"} + + ); + return ( + + Held + + ); + }; + + const shell = ` + w-[260px] rounded-2xl overflow-hidden shadow-sm border + ${status === "released" + ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800/40' + : status === "disputed" + ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800/40' + : status === "refunded" || status === "cancelled" || status === "expired" + ? 'bg-gray-50 dark:bg-gray-800/30 border-gray-200 dark:border-gray-700' + : isMe + ? 'bg-white dark:bg-darkBg-card border-brand-green/20 dark:border-brand-gold/20' + : 'bg-white dark:bg-darkBg-card border-gray-100 dark:border-darkBorder-light' + } + `.trim(); + + const stripColor = status === "released" + ? 'bg-green-500' + : status === "disputed" + ? 'bg-red-500' + : status === "refunded" || status === "cancelled" || status === "expired" + ? 'bg-gray-400' + : 'bg-brand-green dark:bg-brand-gold'; + + return ( +
+
+ +
+
+ + + Escrow + +
+ {statusPill()} +
+ +
+

+ {fmt(data.amount, data.currency)} +

+
+ +
+
+

From

+

{data.payerName}

+
+ +
+

To

+

{data.payeeName}

+
+
+ + {data.description && ( +
+

"{data.description}"

+
+ )} + + {isHeld && data.releaseMode === "auto_timeout" && data.autoReleaseAt && ( +

+ Auto-releases {new Date(data.autoReleaseAt).toLocaleDateString()} +

+ )} + + {escrow?.status === "disputed" && escrow?.disputeReason && ( +
+

Dispute: {escrow.disputeReason}

+
+ )} + + {isHeld && (isPayer || isPayee) && ( +
+
+ + {!showDisputeForm ? ( + <> + {isPayer && ( +
+ + +
+ )} + {isPayee && ( +

Waiting for {data.payerName} to release

+ )} + + + ) : ( +
+ setDisputeReason(e.target.value)} + className="h-8 text-xs" + autoFocus + /> +
+ + +
+
+ )} +
+ )} + +

+ {fmtTime(data.timestamp)} +

+
+ ); +}; diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index de898fb..13b560c 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -4,6 +4,7 @@ import type { Message, LegacyMessage, ReplyPreview, Reaction } from "@/types/cha import { useMemo, useRef, useState, useCallback } from "react" import MediaMessageContent from "./media-message-content" import { MoneyMessageCard } from "./money-message-card" +import { EscrowMessageCard } from "./escrow-message-card" import { GroupContributionCard } from "./group-contribution-card" import MessageText from "./message-text" import LinkPreviewCard from "./link-preview-card" @@ -60,12 +61,26 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { ['image', 'video', 'audio', 'document'].includes(message.messageType); const isMoneyMessage = !isLegacy && message.messageType === "money"; + const isEscrowMessage = !isLegacy && message.messageType === "escrow"; let messageContent: any = isLegacy ? message.message : message.content; let moneyTransferData = null; let groupContributionData = null; + let escrowData = null; let isOldMoneyMessage = false; + // Parse escrow hold data + if (isEscrowMessage && messageContent) { + try { + const parsed = JSON.parse(messageContent); + if (parsed.type === 'escrow' && parsed.escrowId) { + escrowData = parsed; + } + } catch (e) { + // Parsing failed - ignore, falls through to plain text rendering + } + } + // Parse money transfer/request data if (isMoneyMessage && messageContent) { try { @@ -88,7 +103,7 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { } } - if (!isMediaMessage && !moneyTransferData) { + if (!isMediaMessage && !moneyTransferData && !escrowData) { if (typeof messageContent === 'object' && messageContent !== null) { if (messageContent.content) { messageContent = messageContent.content; @@ -235,7 +250,16 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
); } - + + // Render escrow hold message as a special card + if (isEscrowMessage && escrowData) { + return ( +
+ +
+ ); + } + return (
{!isMe && ( diff --git a/components/chat/send-money-modal.tsx b/components/chat/send-money-modal.tsx index 71c430e..cfbb6c4 100644 --- a/components/chat/send-money-modal.tsx +++ b/components/chat/send-money-modal.tsx @@ -4,11 +4,12 @@ import { useState, useEffect } from "react" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { DollarSign, X, ArrowRight, Send, Wallet, AlertCircle, Loader2 } from "lucide-react" +import { DollarSign, X, ArrowRight, Send, Wallet, AlertCircle, Loader2, Lock } from "lucide-react" import type { Conversation } from "@/types" import { toast } from "@/hooks/use-toast" import Input from "../ui/Input-ant" import { useSendMoneyInChatMutation, useGetUserWalletBalanceQuery } from "@/states/chatSlice" +import { useCreateEscrowMutation } from "@/states/escrowSlice" import { useChat } from "@/context/ChatContext" import { useAuthToken } from "@/hooks/use-auth-token" import { PinSetupModal } from "@/components/PinSetupModal" @@ -26,6 +27,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren const userId = getUserId() const [sendMoney, { isLoading: loading }] = useSendMoneyInChatMutation() + const [createEscrow, { isLoading: escrowLoading }] = useCreateEscrowMutation() const { data: walletData, refetch: refetchBalance } = useGetUserWalletBalanceQuery( { userId: userId || '' }, { skip: !userId || !isOpen } @@ -37,6 +39,14 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren const [animateAmount, setAnimateAmount] = useState(false) const [pin, setPin] = useState("") const [showPinSetup, setShowPinSetup] = useState(false) + const [holdUntilConfirmed, setHoldUntilConfirmed] = useState(false) + const [releaseMode, setReleaseMode] = useState<"manual" | "auto_timeout">("manual") + const [autoReleaseDays, setAutoReleaseDays] = useState(3) + + const submitting = loading || escrowLoading + // currentConversation is typed as the legacy Group|Contact union, but callers actually pass + // the richer chat object (cast `as any` at the call site) - participants lives there at runtime. + const payeeUserId = (currentConversation as any)?.participants?.find((p: { userId: string }) => p.userId !== userId)?.userId const walletBalance = walletData?.data?.balance || null const loadingBalance = !walletData && isOpen @@ -48,6 +58,9 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren setNote("") setPin("") setAnimateAmount(false) + setHoldUntilConfirmed(false) + setReleaseMode("manual") + setAutoReleaseDays(3) if (userId) { refetchBalance() } @@ -102,30 +115,73 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren const chatId = currentConversation?.id || activeChat! + if (holdUntilConfirmed && !payeeUserId) { + toast({ + title: "Error", + description: "Escrow holds are only available in direct chats right now", + variant: "destructive", + }) + return + } + try { - const result = await sendMoney({ - chatId, - data: { amount, pin, note } - }).unwrap() + if (holdUntilConfirmed && payeeUserId) { + const autoReleaseAt = releaseMode === "auto_timeout" + ? new Date(Date.now() + autoReleaseDays * 24 * 60 * 60 * 1000).toISOString() + : undefined + + const result = await createEscrow({ + chatId, + payeeUserId, + amount, + description: note, + releaseMode, + autoReleaseAt, + pin, + }).unwrap() + + if (result.success) { + toast({ + title: "Funds Held in Escrow", + description: result.message || `$${amount.toFixed(2)} is held until you release it to ${currentConversation?.name}`, + }) + + if (result.chatMessage && addMessage) { + addMessage(result.chatMessage) + } + + if (refreshMessages) { + refreshMessages(String(chatId)) + } + + refetchBalance() + onClose() + } + } else { + const result = await sendMoney({ + chatId, + data: { amount, pin, note } + }).unwrap() - if (result.success) { - const actionText = currentConversation?.isGroup ? 'donated' : 'sent'; - toast({ - title: currentConversation?.isGroup ? "Donation Sent Successfully!" : "Money Sent Successfully!", - description: result.message || `$${amount.toFixed(2)} ${actionText} to ${currentConversation?.name}`, - }) + if (result.success) { + const actionText = currentConversation?.isGroup ? 'donated' : 'sent'; + toast({ + title: currentConversation?.isGroup ? "Donation Sent Successfully!" : "Money Sent Successfully!", + description: result.message || `$${amount.toFixed(2)} ${actionText} to ${currentConversation?.name}`, + }) - if (result.data?.message && addMessage) { - addMessage(result.data.message) - } + if (result.data?.message && addMessage) { + addMessage(result.data.message) + } - if (refreshMessages) { - refreshMessages(String(chatId)) - } + if (refreshMessages) { + refreshMessages(String(chatId)) + } - refetchBalance() + refetchBalance() - onClose() + onClose() + } } } catch (error: any) { const errorData = error?.data || {} @@ -172,10 +228,10 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren const renderStep1 = () => (
-

+

{currentConversation?.isGroup ? 'How much do you want to donate?' : 'How much do you want to send?'}

-

+

{currentConversation?.isGroup ? 'Enter the amount you want to contribute' : 'Enter the amount you want to transfer'} @@ -201,7 +257,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren className={`relative mt-4 mb-6 transition-all duration-500 transform ${animateAmount ? "scale-110 opacity-100" : "scale-95 opacity-0"}`} >

- +
setAmount(quickAmount)} disabled={walletBalance !== null && quickAmount > walletBalance} > @@ -234,6 +290,61 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren ))}
+ + {!currentConversation?.isGroup && payeeUserId && ( +
+ + + {holdUntilConfirmed && ( +
+ + +
+ )} +
+ )}
) @@ -241,19 +352,31 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren

Review and verify

-

Confirm the details and enter your PIN

+

Confirm the details and enter your PIN

-
+ {holdUntilConfirmed && payeeUserId && ( +
+ + + This will be held in escrow, not sent right away.{" "} + {releaseMode === "auto_timeout" + ? `It auto-releases in ${autoReleaseDays} day${autoReleaseDays === 1 ? "" : "s"} unless you cancel or dispute it first.` + : "You'll need to release it manually once you're satisfied."} + +
+ )} + +
- From - Your Account + From + Your Account
- +
- To + To
{currentConversation?.avatar && ( @@ -261,19 +384,19 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren {currentConversation.name.charAt(0).toUpperCase()} )} - {currentConversation?.name || "Recipient"} + {currentConversation?.name || "Recipient"}
-
+
- Amount + Amount ${amount.toFixed(2)}
-

Enter your PIN to confirm

+

Enter your PIN to confirm

-

Add a note (optional)

+

Add a note (optional)

Back @@ -345,14 +468,19 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren ) : (
diff --git a/components/chat/chat-page-clean.tsx b/components/chat/chat-page-clean.tsx index 9047604..1528dd9 100644 --- a/components/chat/chat-page-clean.tsx +++ b/components/chat/chat-page-clean.tsx @@ -23,6 +23,7 @@ import { import SendMoneyModal from '@/components/chat/send-money-modal'; import RequestMoneyModal from '@/components/chat/request-money-modal'; import CreateContributionModal from '@/components/chat/create-contribution-modal'; +import CreateGroupModalUpdated from '@/components/chat/create-group-modal'; import { getGroupById } from '@/helpers/api'; import AddContactModal from '@/components/chat/add-contact-modal'; import UserProfileModal from '@/components/chat/user-profile-modal'; @@ -84,6 +85,8 @@ export default function ChatPageClean() { const [isGroupAdmin, setIsGroupAdmin] = useState(false); const [isCreateContributionModalOpen, setIsCreateContributionModalOpen] = useState(false); + const [isCreateGroupModalOpen, setIsCreateGroupModalOpen] = + useState(false); const [selectedOutsideMessage, setSelectedOutsideMessage] = useState(null); const [isGroupSettingsModalOpen, setIsGroupSettingsModalOpen] = @@ -404,6 +407,7 @@ export default function ChatPageClean() { onCreateContribution={() => setIsCreateContributionModalOpen(true) } + onCreateGroup={() => setIsCreateGroupModalOpen(true)} isGroupAdmin={isGroupAdmin} onViewProfile={handleViewProfile} onInviteToGroup={handleInviteToGroup} @@ -461,6 +465,12 @@ export default function ChatPageClean() { conversation={selectedChat} /> + setIsCreateGroupModalOpen(false)} + token={token} + /> + setIsAddContactModalOpen(false)} diff --git a/components/chat/escrow-message-card.tsx b/components/chat/escrow-message-card.tsx index 07f6404..a1c4acd 100644 --- a/components/chat/escrow-message-card.tsx +++ b/components/chat/escrow-message-card.tsx @@ -8,6 +8,7 @@ import { getCurrentUserId } from "@/utils/tokenUtils"; import { useGetEscrowByIdQuery, useReleaseEscrowMutation, + useFulfillEscrowMutation, useRefundEscrowMutation, useDisputeEscrowMutation, } from "@/states/escrowSlice"; @@ -57,6 +58,7 @@ export const EscrowMessageCard: React.FC = ({ data, isMe const escrow = escrowResp?.data; const [releaseEscrow, { isLoading: releasing }] = useReleaseEscrowMutation(); + const [fulfillEscrow, { isLoading: fulfilling }] = useFulfillEscrowMutation(); const [refundEscrow, { isLoading: refunding }] = useRefundEscrowMutation(); const [disputeEscrow, { isLoading: disputing }] = useDisputeEscrowMutation(); @@ -67,6 +69,7 @@ export const EscrowMessageCard: React.FC = ({ data, isMe const isPayer = Boolean(escrow?.payerUserId && currentUserId && escrow.payerUserId === currentUserId); const isPayee = Boolean(escrow?.payeeUserId && currentUserId && escrow.payeeUserId === currentUserId); const isHeld = status === "held"; + const isFulfilled = Boolean(escrow?.fulfilledAt); const handleRelease = async () => { try { @@ -77,6 +80,15 @@ export const EscrowMessageCard: React.FC = ({ data, isMe } }; + const handleFulfill = async () => { + try { + await fulfillEscrow({ escrowId: data.escrowId, chatId }).unwrap(); + toast({ title: "Marked as fulfilled", description: `${data.payerName} can now release the funds.` }); + } catch (err: any) { + toast({ title: "Could not mark as fulfilled", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + const handleRefund = async () => { try { await refundEscrow({ escrowId: data.escrowId, chatId }).unwrap(); @@ -206,19 +218,38 @@ export const EscrowMessageCard: React.FC = ({ data, isMe {!showDisputeForm ? ( <> + {isFulfilled && ( +

+ + {isPayee + ? (escrow?.autoReleaseAt + ? `You'll receive this automatically on ${new Date(escrow.autoReleaseAt).toLocaleDateString()} if ${data.payerName} hasn't released it by then` + : "Marked as fulfilled — waiting for release") + : (escrow?.autoReleaseAt + ? `Marked as fulfilled by ${data.payeeName} — auto-releases to them on ${new Date(escrow.autoReleaseAt).toLocaleDateString()} if you don't act` + : "Marked as fulfilled")} +

+ )} + {isPayer && (
- + {!isFulfilled && ( + + )}
)} - {isPayee && ( -

Waiting for {data.payerName} to release

+ + {isPayee && !isFulfilled && ( + )} +