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 && (
+
+ {isCancelling ? : null}
+ Cancel all
+
+ )}
+
+
+
+
+ {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) => (
+
+
+
+
+ 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 && (
+
{
+ e.stopPropagation()
+ router.push(`/home/scheduled-transfers/batches/${item.scheduledBatchId}`)
+ }}
+ className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800 flex items-center gap-1 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
+ >
+ Part of batch
+
+ )}
+
+
+
+
+ {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" ? (
+
runAction(item.id, () => resumeScheduledTransfer(item.id), "Resumed")}
+ >
+ Resume
+
+ ) : (
+ isRecurring && (
+ <>
+
runAction(item.id, () => pauseScheduledTransfer(item.id), "Paused")}
+ >
+ Pause
+
+
+ runAction(item.id, () => skipNextScheduledOccurrence(item.id), "Occurrence skipped")
+ }
+ >
+ Skip next
+
+ >
+ )
+ )}
+
runAction(item.id, () => cancelScheduledTransfer(item.id), "Cancelled")}
+ >
+ Cancel
+
+
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scheduled Transfers
+
+ Money set to send later
+
+
+
+
router.push("/home/transfer")}>
+ Schedule new
+
+
+
+
+
+
+
+ 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 (
+
+
+
+
+
+
+
+
+
(step === 1 ? router.push("/home/transfer") : setStep((step - 1) as 1 | 2))}
+ className="p-2 hover:bg-gray-100 dark:hover:bg-darkBg-interactive rounded-full transition text-gray-700 dark:text-gray-300"
+ >
+
+
+
+ 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"
+ />
+
+ Split evenly
+
+
+
+ )}
+
+
+ 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}
+
+ )}
+
+
+ Continue to PIN
+
+
+ )}
+
+ {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.
+
+
+ )}
+
+
+
+
+ {loading ? (
+
+ ) : schedule.enabled ? (
+ `Confirm Schedule for ${selectedContacts.length}`
+ ) : (
+ `Confirm & Send to ${selectedContacts.length}`
+ )}
+
+
+
{
+ setStep(1);
+ setPin("");
+ setError("");
+ }}
+ className="w-full mt-4 text-sm font-semibold text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
+ >
+ Go Back
+
+
+ )}
+
+ {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
+
+ )}
+
+
+
+
+
+ router.push(
+ resultSummary.scheduled
+ ? `/home/scheduled-transfers/batches/${resultSummary.batchId}`
+ : "/home/transfer"
+ )
+ }
+ className="w-full mt-6 bg-brand-green dark:bg-brand-gold text-white dark:text-darkBg-main font-bold py-4 rounded-2xl shadow-lg hover:opacity-90 transition-all"
+ >
+ {resultSummary.scheduled ? "View Scheduled Batch" : "Done"}
+
+
+ )}
+
+ {!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 && }
+
+
+
+
+
+
{contact.name}
+
{contact.phone}
+
+
+
+ {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 (
+
+
+ update({ enabled: false })}
+ className={cn(
+ "flex items-center justify-center gap-2 py-3 rounded-2xl text-sm font-semibold border transition-all",
+ !schedule.enabled
+ ? "bg-brand-green dark:bg-brand-gold text-white dark:text-darkBg-main border-transparent shadow-md"
+ : "bg-gray-50 dark:bg-darkBg-main text-gray-600 dark:text-gray-400 border-gray-200 dark:border-darkBorder-light"
+ )}
+ >
+ Send now
+
+ update({ enabled: true })}
+ className={cn(
+ "flex items-center justify-center gap-2 py-3 rounded-2xl text-sm font-semibold border transition-all",
+ schedule.enabled
+ ? "bg-brand-green dark:bg-brand-gold text-white dark:text-darkBg-main border-transparent shadow-md"
+ : "bg-gray-50 dark:bg-darkBg-main text-gray-600 dark:text-gray-400 border-gray-200 dark:border-darkBorder-light"
+ )}
+ >
+ Schedule for later
+
+
+
+ {schedule.enabled && (
+
+
+
+
+
+ Repeat
+
+
+ {frequencies.map((f) => (
+ updateRecurrence({ frequency: f.value })}
+ className={cn(
+ "py-2 rounded-lg text-[11px] font-semibold border transition-all",
+ schedule.recurrence.frequency === f.value
+ ? "bg-brand-green/10 dark:bg-brand-gold/10 border-brand-green dark:border-brand-gold text-brand-green dark:text-brand-gold"
+ : "bg-gray-50 dark:bg-darkBg-main border-gray-200 dark:border-darkBorder-light text-gray-500 dark:text-gray-400"
+ )}
+ >
+ {f.label}
+
+ ))}
+
+
+
+ {schedule.recurrence.frequency !== "none" && (
+
+
+
+ Ends
+
+
+ {endOptions.map((opt) => (
+ updateRecurrence({ endMode: opt.value })}
+ className={cn(
+ "py-2 rounded-lg text-[11px] font-semibold border transition-all",
+ schedule.recurrence.endMode === opt.value
+ ? "bg-white dark:bg-darkBg-card border-brand-green dark:border-brand-gold text-brand-green dark:text-brand-gold shadow-sm"
+ : "bg-transparent border-gray-200 dark:border-darkBorder-light text-gray-500 dark:text-gray-400"
+ )}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+ {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
+ router.push("/home/transfer/batch")}
+ className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-gray-200 dark:border-darkBorder-light text-xs font-semibold text-gray-600 dark:text-gray-300 hover:border-brand-green dark:hover:border-brand-gold hover:text-brand-green dark:hover:text-brand-gold transition-colors"
+ >
+
+ Multiple
+
+ router.push("/home/scheduled-transfers")}
+ className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-gray-200 dark:border-darkBorder-light text-xs font-semibold text-gray-600 dark:text-gray-300 hover:border-brand-green dark:hover:border-brand-gold hover:text-brand-green dark:hover:text-brand-gold transition-colors"
+ >
+
+ Scheduled
+
{/* 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 && (
+
+ )}
+
+ {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 && (
+
+
+ {refunding ? … : 'Cancel'}
+
+
+ {releasing ? … : 'Release'}
+
+
+ )}
+ {isPayee && (
+
Waiting for {data.payerName} to release
+ )}
+
setShowDisputeForm(true)}
+ className="w-full text-[11px] text-red-500 hover:text-red-600 text-center"
+ >
+ Raise a dispute
+
+ >
+ ) : (
+
+
setDisputeReason(e.target.value)}
+ className="h-8 text-xs"
+ autoFocus
+ />
+
+ setShowDisputeForm(false)} disabled={disputing} className="flex-1 h-8 text-xs">Cancel
+
+ {disputing ? … : 'Submit'}
+
+
+
+ )}
+
+ )}
+
+
+ {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 && (
+
+ )}
)
@@ -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
) : (
- {loading ? (
+ {submitting ? (
<>
Processing...
>
+ ) : holdUntilConfirmed && payeeUserId ? (
+ <>
+
+ Hold Funds
+ >
) : (
<>
diff --git a/states/apiSlice.ts b/states/apiSlice.ts
index 5a0bad1..08eafa9 100644
--- a/states/apiSlice.ts
+++ b/states/apiSlice.ts
@@ -46,6 +46,6 @@ const baseQueryWithReauth = async (args: any, api: any, extraOptions: any) => {
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
- tagTypes: ['Group', 'GroupMember', 'GroupInvitation', 'GroupJoinRequest', 'Notification', 'Contact', 'ContactInvitation', 'User', 'Chat', 'ChatMessage', 'ChatStatus', 'Wallet'],
+ tagTypes: ['Group', 'GroupMember', 'GroupInvitation', 'GroupJoinRequest', 'Notification', 'Contact', 'ContactInvitation', 'User', 'Chat', 'ChatMessage', 'ChatStatus', 'Wallet', 'Escrow'],
endpoints: () => ({})
})
diff --git a/states/escrowSlice.ts b/states/escrowSlice.ts
new file mode 100644
index 0000000..0ee67be
--- /dev/null
+++ b/states/escrowSlice.ts
@@ -0,0 +1,79 @@
+import { apiSlice } from "@/states/apiSlice";
+
+export const escrowSlice = apiSlice.injectEndpoints({
+ endpoints: (builder) => ({
+ createEscrow: builder.mutation({
+ query: (data) => ({
+ url: "/escrows",
+ method: "POST",
+ body: data
+ }),
+ invalidatesTags: (result, error, { chatId }) => [
+ "Escrow",
+ { type: "ChatMessage", id: chatId }
+ ]
+ }),
+
+ getMyEscrows: builder.query({
+ query: ({ status, role }: { status?: string; role?: "payer" | "payee" } = {}) => {
+ const params = new URLSearchParams();
+ if (status) params.set("status", status);
+ if (role) params.set("role", role);
+ const qs = params.toString();
+ return `/escrows${qs ? `?${qs}` : ""}`;
+ },
+ providesTags: ["Escrow"]
+ }),
+
+ getEscrowById: builder.query({
+ query: ({ escrowId }) => `/escrows/${escrowId}`,
+ providesTags: (result, error, { escrowId }) => [{ type: "Escrow", id: escrowId }]
+ }),
+
+ releaseEscrow: builder.mutation({
+ query: ({ escrowId }) => ({
+ url: `/escrows/${escrowId}/release`,
+ method: "POST"
+ }),
+ invalidatesTags: (result, error, { escrowId, chatId }) => [
+ "Escrow",
+ { type: "Escrow", id: escrowId },
+ { type: "ChatMessage", id: chatId }
+ ]
+ }),
+
+ refundEscrow: builder.mutation({
+ query: ({ escrowId }) => ({
+ url: `/escrows/${escrowId}/refund`,
+ method: "POST"
+ }),
+ invalidatesTags: (result, error, { escrowId, chatId }) => [
+ "Escrow",
+ { type: "Escrow", id: escrowId },
+ { type: "ChatMessage", id: chatId }
+ ]
+ }),
+
+ disputeEscrow: builder.mutation({
+ query: ({ escrowId, reason }) => ({
+ url: `/escrows/${escrowId}/dispute`,
+ method: "POST",
+ body: { reason }
+ }),
+ invalidatesTags: (result, error, { escrowId, chatId }) => [
+ "Escrow",
+ { type: "Escrow", id: escrowId },
+ { type: "ChatMessage", id: chatId }
+ ]
+ }),
+ }),
+});
+
+export const {
+ useCreateEscrowMutation,
+ useGetMyEscrowsQuery,
+ useGetEscrowByIdQuery,
+ useReleaseEscrowMutation,
+ useRefundEscrowMutation,
+ useDisputeEscrowMutation,
+} = escrowSlice;
diff --git a/types/chat.types.ts b/types/chat.types.ts
index 900e41d..066a8bf 100644
--- a/types/chat.types.ts
+++ b/types/chat.types.ts
@@ -1,4 +1,4 @@
-export type MessageType = "text" | "image" | "file" | "money" | "audio" | "video" | "document";
+export type MessageType = "text" | "image" | "file" | "money" | "escrow" | "audio" | "video" | "document";
export type MessageStatus = "sent" | "delivered" | "read";
export type ChatType = "dm" | "group" | "support";
From e093d67de6c4fe05b75cb566b9911374057ad7ff Mon Sep 17 00:00:00 2001
From: Olivier BYIRINGIRO
Date: Mon, 17 Aug 2026 14:50:39 +0200
Subject: [PATCH 3/3] feat(chat): add group creation and escrow fulfillment
functionalities
---
components/chat/chat-area.tsx | 6 ++++
components/chat/chat-page-clean.tsx | 10 ++++++
components/chat/escrow-message-card.tsx | 41 ++++++++++++++++++++---
components/chat/message-input.tsx | 28 ++++++++++++++++
context/ChatContext.tsx | 44 ++++++++++++++++++-------
helpers/api.ts | 1 -
services/secureChatService.ts | 20 +++++++++++
states/escrowSlice.ts | 13 ++++++++
utils/chatPreview.ts | 8 ++++-
utils/messageUtils.ts | 15 ++++++++-
10 files changed, 167 insertions(+), 19 deletions(-)
diff --git a/components/chat/chat-area.tsx b/components/chat/chat-area.tsx
index 3a86e2f..cb24d76 100644
--- a/components/chat/chat-area.tsx
+++ b/components/chat/chat-area.tsx
@@ -67,6 +67,7 @@ interface ChatAreaProps {
onDeleteGroup?: () => void
onVerifySecurity?: () => void
onCreateContribution?: () => void
+ onCreateGroup?: () => void
isGroupAdmin?: boolean
typingUsers?: any[]
onlineUsers?: any[]
@@ -85,6 +86,7 @@ export default function ChatArea({
onDeleteGroup,
onVerifySecurity,
onCreateContribution,
+ onCreateGroup,
isGroupAdmin = false,
typingUsers = [],
onlineUsers = [],
@@ -196,6 +198,10 @@ export default function ChatArea({
secureConversation={conversation}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(null)}
+ onSendMoney={onSendMoney}
+ onRequestMoney={onRequestMoney}
+ onCreateContribution={onCreateContribution}
+ onCreateGroup={onCreateGroup}
/>
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 && (
-
- {refunding ? … : 'Cancel'}
-
+ {!isFulfilled && (
+
+ {refunding ? … : 'Cancel'}
+
+ )}
{releasing ? … : 'Release'}
)}
- {isPayee && (
- Waiting for {data.payerName} to release
+
+ {isPayee && !isFulfilled && (
+
+ {fulfilling ? … : 'Mark as Fulfilled'}
+
)}
+
setShowDisputeForm(true)}
className="w-full text-[11px] text-red-500 hover:text-red-600 text-center"
diff --git a/components/chat/message-input.tsx b/components/chat/message-input.tsx
index a896e22..42e0e54 100644
--- a/components/chat/message-input.tsx
+++ b/components/chat/message-input.tsx
@@ -52,6 +52,10 @@ interface MessageInputProps {
secureConversation?: Conversation
replyToMessage?: ReplyPreview | null
onCancelReply?: () => void
+ onSendMoney?: () => void
+ onRequestMoney?: () => void
+ onCreateContribution?: () => void
+ onCreateGroup?: () => void
}
export default function MessageInput({
@@ -62,6 +66,10 @@ export default function MessageInput({
secureConversation,
replyToMessage = null,
onCancelReply,
+ onSendMoney,
+ onRequestMoney,
+ onCreateContribution,
+ onCreateGroup,
}: MessageInputProps) {
const [messageText, setMessageText] = useState("")
const [showOptions, setShowOptions] = useState(false)
@@ -384,6 +392,26 @@ export default function MessageInput({
setShowMediaModal(true)
return
}
+ if (option === "Send Money") {
+ if (onSendMoney) { onSendMoney(); return }
+ toast({ title: "Unavailable", description: "Open this chat to send money", variant: "destructive" })
+ return
+ }
+ if (option === "Request Money") {
+ if (onRequestMoney) { onRequestMoney(); return }
+ toast({ title: "Unavailable", description: "Open this chat to request money", variant: "destructive" })
+ return
+ }
+ if (option === "Contribution") {
+ if (onCreateContribution) { onCreateContribution(); return }
+ toast({ title: "Unavailable", description: "Open this chat to start a contribution", variant: "destructive" })
+ return
+ }
+ if (option === "Group") {
+ if (onCreateGroup) { onCreateGroup(); return }
+ toast({ title: "Unavailable", description: "Unable to open group creation right now", variant: "destructive" })
+ return
+ }
toast({ title: "Coming soon", description: option })
}
diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx
index 55d265a..c787c53 100644
--- a/context/ChatContext.tsx
+++ b/context/ChatContext.tsx
@@ -71,6 +71,7 @@ type NotificationMessageType = "text" | "image" | "video" | "audio" | "file" | "
const toNotificationMessageType = (messageType: MessageType): NotificationMessageType => {
if (messageType === "document") return "file";
+ if (messageType === "escrow") return "money";
return messageType;
};
@@ -727,15 +728,24 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
}
if (messagesData?.data?.messages && activeChat) {
- const messagesWithIsMe = messagesData.data.messages.map((msg: Message) => ({
+ const fetchedMessages = messagesData.data.messages.map((msg: Message) => ({
...msg,
isMe: String(msg.sender.id) === String(userId || "")
}));
- setMessages((prev: Record) => ({
- ...prev,
- [activeChat]: messagesWithIsMe
- }));
+ // Merge (don't replace) - an in-flight fetch started before a message was
+ // sent can resolve after the optimistic append and must not erase it.
+ setMessages((prev: Record) => {
+ const existing = prev[activeChat] || [];
+ const byId = new Map(existing.map((msg) => [msg.id, msg]));
+ for (const msg of fetchedMessages) {
+ byId.set(msg.id, msg);
+ }
+ const merged = Array.from(byId.values()).sort(
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
+ );
+ return { ...prev, [activeChat]: merged };
+ });
}
}, [messagesData, activeChat, userId, isActiveSecureChat]);
@@ -756,14 +766,26 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
if (cancelled) return;
- setMessages((prev: Record) => ({
- ...prev,
- [activeChat]: secureMessages.map((message) => ({
- ...message,
- isMe: String(message.sender.id) === String(userId),
- })),
+ const fetchedMessages = secureMessages.map((message) => ({
+ ...message,
+ isMe: String(message.sender.id) === String(userId),
}));
+ // Merge (don't replace) - same rationale as the plain-chat sync: an
+ // in-flight fetch can resolve after a locally-added message and must
+ // not erase it.
+ setMessages((prev: Record) => {
+ const existing = prev[activeChat] || [];
+ const byId = new Map(existing.map((msg) => [msg.id, msg]));
+ for (const msg of fetchedMessages) {
+ byId.set(msg.id, msg);
+ }
+ const merged = Array.from(byId.values()).sort(
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
+ );
+ return { ...prev, [activeChat]: merged };
+ });
+
const latestMessage = secureMessages[secureMessages.length - 1] || null;
if (latestMessage && !latestMessage.content.startsWith("[Unable to decrypt")) {
setConversations((prev: Conversation[]) => {
diff --git a/helpers/api.ts b/helpers/api.ts
index 9b58678..08abfb9 100644
--- a/helpers/api.ts
+++ b/helpers/api.ts
@@ -27,7 +27,6 @@ const apiPost = (url: string, data: any) => axios.post(`${baseUrl}${url}`, data,
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() });
-const apiPatch = (url: string, data: any) => axios.patch(`${baseUrl}${url}`, data, { headers: getAuthHeaders() });
// Helper for FormData requests (no Content-Type header, let browser set it with boundary)
const apiPostFormData = (url: string, formData: FormData) =>
diff --git a/services/secureChatService.ts b/services/secureChatService.ts
index d4fccca..71fe083 100644
--- a/services/secureChatService.ts
+++ b/services/secureChatService.ts
@@ -320,6 +320,26 @@ export const fetchSecureChatMessages = async ({
const decryptedMessages = await Promise.all(
((payload?.data?.messages as any[]) || []).map(async (rawMessage) => {
+ // Plain (non-E2EE) messages - money/escrow holds, etc. - carry their real
+ // content already; there's no envelope to decrypt.
+ if (!rawMessage.isEncrypted) {
+ return {
+ id: rawMessage.id,
+ chatId: rawMessage.chatId,
+ content: rawMessage.content || "",
+ messageType: rawMessage.messageType,
+ replyToMessageId: rawMessage.replyToMessageId || null,
+ replyTo: null,
+ reactions: [],
+ status: rawMessage.status,
+ deliveredAt: rawMessage.deliveredAt || undefined,
+ readAt: rawMessage.readAt || undefined,
+ createdAt: rawMessage.createdAt,
+ sender: rawMessage.sender,
+ readBy: rawMessage.readBy || [],
+ deliveryConfirmed: Boolean(rawMessage.deliveredAt),
+ } satisfies Message;
+ }
try {
return await decryptSecureApiMessage({ rawMessage, state, token });
} catch (error) {
diff --git a/states/escrowSlice.ts b/states/escrowSlice.ts
index 0ee67be..7d316a5 100644
--- a/states/escrowSlice.ts
+++ b/states/escrowSlice.ts
@@ -42,6 +42,18 @@ export const escrowSlice = apiSlice.injectEndpoints({
]
}),
+ fulfillEscrow: builder.mutation({
+ query: ({ escrowId }) => ({
+ url: `/escrows/${escrowId}/fulfill`,
+ method: "POST"
+ }),
+ invalidatesTags: (result, error, { escrowId, chatId }) => [
+ "Escrow",
+ { type: "Escrow", id: escrowId },
+ { type: "ChatMessage", id: chatId }
+ ]
+ }),
+
refundEscrow: builder.mutation({
query: ({ escrowId }) => ({
url: `/escrows/${escrowId}/refund`,
@@ -74,6 +86,7 @@ export const {
useGetMyEscrowsQuery,
useGetEscrowByIdQuery,
useReleaseEscrowMutation,
+ useFulfillEscrowMutation,
useRefundEscrowMutation,
useDisputeEscrowMutation,
} = escrowSlice;
diff --git a/utils/chatPreview.ts b/utils/chatPreview.ts
index 2b98338..91a0f33 100644
--- a/utils/chatPreview.ts
+++ b/utils/chatPreview.ts
@@ -1,4 +1,5 @@
import type { MessageType } from "@/types/chat.types";
+import { parseMessageContent } from "@/utils/messageUtils";
const mediaPreviewLabels: Partial> = {
image: "Image",
@@ -26,5 +27,10 @@ export const getChatPreviewText = ({
return mediaPreviewLabels[messageType as MessageType] || "File";
}
- return content || "";
+ if (!content) return "";
+ if (messageType === "money" || messageType === "escrow") {
+ return parseMessageContent(content, messageType);
+ }
+
+ return content;
};
diff --git a/utils/messageUtils.ts b/utils/messageUtils.ts
index 7469a2b..2e5676c 100644
--- a/utils/messageUtils.ts
+++ b/utils/messageUtils.ts
@@ -1,5 +1,5 @@
export function parseMessageContent(content: string, messageType: string): string {
- if (messageType !== 'money') {
+ if (messageType !== 'money' && messageType !== 'escrow') {
return content;
}
@@ -46,6 +46,19 @@ export function parseMessageContent(content: string, messageType: string): strin
}).format(amount)} (${status})${note}`;
}
+ if (data.type === 'escrow') {
+ const amount = data.amount || 0;
+ const currency = data.currency || 'RWF';
+ const formatted = new Intl.NumberFormat('en-RW', {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(amount);
+
+ return `🔒 Held ${formatted} in escrow for ${data.payeeName || 'recipient'}`;
+ }
+
if (data.type === 'group_contribution') {
const amount = data.goalAmount || 0;
const currency = data.currency || 'RWF';