From dd86cd37bc5bf352535348d11654dc1e4a1144af Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Mon, 21 Sep 2026 15:24:22 +0200 Subject: [PATCH 1/2] feat: enhance notification center with shared wallet notifications and group invitation handling - Added new icons and notification types for shared wallet events. - Implemented handling for group invitations, including accept/reject functionality. - Updated notification rendering logic to accommodate new notification types. - Refactored notification click handling to navigate to shared wallet details. feat: improve contact list item for selection mode - Introduced selection mode in ContactListItem for batch actions. - Added visual indicators for selected contacts. - Updated button click handling to toggle selection. feat: enhance schedule picker with mode toggle option - Added prop to hide the built-in mode toggle for scheduling. - Updated layout to conditionally render scheduling options based on the new prop. feat: implement selection mode in transfer page layout - Added functionality to enter/exit selection mode for multiple contact selection. - Implemented logic to handle selected contacts and navigate to batch transfer. feat: add scheduled transfer status updates to chat context - Implemented socket listeners for scheduled transfer updates. - Updated chat messages to reflect the status of scheduled transfers. feat: extend API for shared wallet functionality - Added endpoints for creating, managing, and interacting with shared wallets. - Implemented withdrawal and deposit functionalities for shared wallets. feat: update types for shared wallet integration - Extended existing types to include shared wallet attributes and notifications. - Added new types for shared wallet withdrawal and member management. feat: add currency formatting utility - Introduced a utility function to format currency amounts consistently. --- app/home/transfer/batch/page.tsx | 115 ++---- app/wallets/shared/[sharedWalletId]/page.tsx | 34 ++ .../Wallet/AddSharedWalletMemberModal.tsx | 144 +++++++ components/Wallet/CreateSharedWalletModal.tsx | 262 ++++++++++++ .../Wallet/DepositIntoSharedWalletModal.tsx | 155 +++++++ components/Wallet/SharedWalletDetail.tsx | 377 ++++++++++++++++++ components/Wallet/SharedWallets.tsx | 117 ++++++ components/Wallet/WalletView.tsx | 23 +- .../Wallet/WithdrawFromSharedWalletModal.tsx | 175 ++++++++ components/chat/chat-area.tsx | 6 + components/chat/chat-header.tsx | 31 +- components/chat/chat-page-clean.tsx | 19 + .../chat/create-shared-wallet-modal.tsx | 270 +++++++++++++ components/chat/escrow-message-card.tsx | 148 +++++-- components/chat/message-input.tsx | 14 + components/chat/message-item.tsx | 24 +- components/chat/money-message-card.tsx | 193 +++++++-- components/chat/options-dropdown.tsx | 6 + components/chat/request-money-modal.tsx | 12 +- components/chat/send-money-batch-modal.tsx | 336 ++++++++++++++++ components/chat/send-money-modal.tsx | 222 ++++++++--- .../chat/shared-wallet-balance-badge.tsx | 31 ++ .../chat/shared-wallet-withdrawal-card.tsx | 256 ++++++++++++ .../notifications/NotificationCenter.tsx | 107 ++++- components/transfer/ContactListItem.tsx | 39 +- components/transfer/SchedulePicker.tsx | 64 +-- components/transfer/TransferPageLayout.tsx | 112 ++++-- context/ChatContext.tsx | 32 ++ helpers/api.ts | 2 + services/socketService.ts | 71 ++++ states/apiSlice.ts | 2 +- states/escrowSlice.ts | 33 +- states/groupSlice.ts | 2 +- states/sharedWalletSlice.ts | 167 ++++++++ types/dashboard.ts | 2 + types/group.types.ts | 5 + types/notification.types.ts | 13 + utils/currency.ts | 8 + 38 files changed, 3348 insertions(+), 281 deletions(-) create mode 100644 app/wallets/shared/[sharedWalletId]/page.tsx create mode 100644 components/Wallet/AddSharedWalletMemberModal.tsx create mode 100644 components/Wallet/CreateSharedWalletModal.tsx create mode 100644 components/Wallet/DepositIntoSharedWalletModal.tsx create mode 100644 components/Wallet/SharedWalletDetail.tsx create mode 100644 components/Wallet/SharedWallets.tsx create mode 100644 components/Wallet/WithdrawFromSharedWalletModal.tsx create mode 100644 components/chat/create-shared-wallet-modal.tsx create mode 100644 components/chat/send-money-batch-modal.tsx create mode 100644 components/chat/shared-wallet-balance-badge.tsx create mode 100644 components/chat/shared-wallet-withdrawal-card.tsx create mode 100644 states/sharedWalletSlice.ts create mode 100644 utils/currency.ts diff --git a/app/home/transfer/batch/page.tsx b/app/home/transfer/batch/page.tsx index a0e9215..a739022 100644 --- a/app/home/transfer/batch/page.tsx +++ b/app/home/transfer/batch/page.tsx @@ -2,11 +2,10 @@ import React, { useState, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Loader2, AlertCircle, Users, CalendarClock } from "lucide-react"; +import { ArrowLeft, Loader2, 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"; @@ -35,8 +34,7 @@ const BatchTransferPage = () => { const accent = useAccent(); const [step, setStep] = useState<1 | 2 | 3>(1); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [recipients, setRecipients] = useState([]); const [amounts, setAmounts] = useState>({}); const [splitTotal, setSplitTotal] = useState(""); const [schedule, setSchedule] = useState(defaultScheduleState); @@ -61,30 +59,30 @@ const BatchTransferPage = () => { 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)); + recipients.forEach((c) => (map[c.id] = c)); return map; - }, [contacts]); + }, [recipients]); - const filteredContacts = contacts.filter( - (c) => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.phone.includes(searchQuery) - ); + // Recipients are chosen on the main Transfer page, not here - read what was picked + // there and bail back if someone lands on this page with nothing selected. + useEffect(() => { + const raw = sessionStorage.getItem("selectedRecipients"); + let parsed: BatchContact[] = []; + try { + parsed = raw ? JSON.parse(raw) : []; + } catch { + parsed = []; + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + router.replace("/home/transfer"); + return; + } + + setRecipients(parsed); + }, [router]); useEffect(() => { const checkPin = async () => { @@ -116,20 +114,14 @@ const BatchTransferPage = () => { 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; + // Selection already happened on the main Transfer page - tapping the checkmark here + // just drops that person from this batch instead of toggling a bigger list. + const removeRecipient = (id: string) => { + setRecipients((prev) => prev.filter((c) => c.id !== id)); + setAmounts((prev) => { + const copy = { ...prev }; + delete copy[id]; + return copy; }); setError(""); }; @@ -139,7 +131,7 @@ const BatchTransferPage = () => { setError(""); }; - const selectedContacts = contacts.filter((c) => selectedIds.has(c.id)); + const selectedContacts = recipients; const total = selectedContacts.reduce((sum, c) => sum + (parseFloat(amounts[c.id]) || 0), 0); const handleSplitEvenly = () => { @@ -245,7 +237,6 @@ const BatchTransferPage = () => { senderOrganizationId, recipients, pin, - description: "Batch transfer", scheduledFor: scheduledDateTime.toISOString(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, recurrence, @@ -273,7 +264,6 @@ const BatchTransferPage = () => { senderOrganizationId, recipients, pin, - description: "Batch transfer", idempotencyKey, }); @@ -370,33 +360,17 @@ const BatchTransferPage = () => { )} -
- 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.
- )} + {recipients.map((contact) => ( + removeRecipient(contact.id)} + onAmountChange={(value) => setAmountFor(contact.id, value)} + /> + ))}
{selectedContacts.length > 0 && ( @@ -536,13 +510,6 @@ const BatchTransferPage = () => { )} - - {!isContactsLoading && contacts.length === 0 && step === 1 && ( -
- - Add some contacts first from the main transfer page before sending to multiple people. -
- )} diff --git a/app/wallets/shared/[sharedWalletId]/page.tsx b/app/wallets/shared/[sharedWalletId]/page.tsx new file mode 100644 index 0000000..5e6f9ba --- /dev/null +++ b/app/wallets/shared/[sharedWalletId]/page.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useParams } from "next/navigation" +import { useSidebar } from "@/context/SidebarContext" +import { Header } from "@/components/Header" +import Navigation from "@/components/Navigation" +import { BackButton } from "@/components/shared/BackButton" +import { useAccent } from "@/hooks/use-accent" +import { cn } from "@/lib/utils" +import SharedWalletDetail from "@/components/Wallet/SharedWalletDetail" + +export default function SharedWalletDetailPage() { + const params = useParams() + const sharedWalletId = params?.sharedWalletId as string + const { isExpanded } = useSidebar() + const accent = useAccent() + + return ( +
+ +
+
+
+
+
+ +
+ {sharedWalletId && } +
+
+
+
+ ) +} diff --git a/components/Wallet/AddSharedWalletMemberModal.tsx b/components/Wallet/AddSharedWalletMemberModal.tsx new file mode 100644 index 0000000..e719a35 --- /dev/null +++ b/components/Wallet/AddSharedWalletMemberModal.tsx @@ -0,0 +1,144 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Loader2, Users } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import { useAddSharedWalletMemberMutation } from "@/states/sharedWalletSlice" +import { useGetAcceptedContactsQuery } from "@/states/contactSlice" +import { useAuthToken } from "@/hooks/use-auth-token" + +interface Contact { + id: string + otherUser: { + id: string + firstName: string + lastName: string + email: string + avatar?: string + } +} + +interface AddSharedWalletMemberModalProps { + isOpen: boolean + onClose: () => void + sharedWalletId: string + existingMemberIds: string[] +} + +export default function AddSharedWalletMemberModal({ + isOpen, + onClose, + sharedWalletId, + existingMemberIds, +}: AddSharedWalletMemberModalProps) { + const { getToken } = useAuthToken() + const token = getToken() + + const [selected, setSelected] = useState([]) + const [addMember, { isLoading: isAdding }] = useAddSharedWalletMemberMutation() + const { data: contactsData, isLoading: isLoadingContacts } = useGetAcceptedContactsQuery(token!, { + skip: !token || !isOpen, + }) + + const contacts: Contact[] = (contactsData?.contacts || []).filter( + (c: Contact) => !existingMemberIds.includes(c.otherUser.id) + ) + + useEffect(() => { + if (isOpen) setSelected([]) + }, [isOpen]) + + const toggle = (userId: string) => { + setSelected((prev) => (prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId])) + } + + const handleSubmit = async () => { + try { + for (const userId of selected) { + await addMember({ sharedWalletId, userId }).unwrap() + } + toast({ title: selected.length === 1 ? "Member added" : `${selected.length} members added` }) + onClose() + } catch (error: any) { + toast({ + title: "Could not add member", + description: error?.data?.message || "Something went wrong", + variant: "destructive", + }) + } + } + + return ( + !open && onClose()}> + + + Add members + + Pick from your contacts who aren't already in this wallet. + + + +
+ {isLoadingContacts ? ( +
+ Loading contacts... +
+ ) : contacts.length === 0 ? ( +
+ + No contacts available to add. +
+ ) : ( +
+ {contacts.map((contact) => ( +
+ toggle(contact.otherUser.id)} + className="mr-2" + /> + +
+ ))} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/components/Wallet/CreateSharedWalletModal.tsx b/components/Wallet/CreateSharedWalletModal.tsx new file mode 100644 index 0000000..d323235 --- /dev/null +++ b/components/Wallet/CreateSharedWalletModal.tsx @@ -0,0 +1,262 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Separator } from "@/components/ui/separator" +import { Input } from "@/components/ui/input" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "@/components/ui/label" +import { WalletCards, Users, Loader2 } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import { useCreateSharedWalletMutation } from "@/states/sharedWalletSlice" +import { useGetAcceptedContactsQuery } from "@/states/contactSlice" +import { useAuthToken } from "@/hooks/use-auth-token" +import PinEntry from "@/components/transfer/PinEntry" +import { PinSetupModal } from "@/components/PinSetupModal" + +interface CreateSharedWalletModalProps { + isOpen: boolean + onClose: () => void +} + +interface Contact { + id: string + otherUser: { + id: string + firstName: string + lastName: string + email: string + avatar?: string + } +} + +type WithdrawalPolicy = "free" | "approval" + +/** + * Standalone creation flow - no group/chat required. This is the Wallet page's + * entry point; components/chat/create-shared-wallet-modal.tsx is the separate + * chat-triggered flow that creates a group with a shared wallet attached. + */ +export default function CreateSharedWalletModal({ isOpen, onClose }: CreateSharedWalletModalProps) { + const { getToken } = useAuthToken() + const token = getToken() + + const [step, setStep] = useState<1 | 2>(1) + const [walletName, setWalletName] = useState("") + const [selectedContacts, setSelectedContacts] = useState([]) + const [withdrawalPolicy, setWithdrawalPolicy] = useState("approval") + const [pin, setPin] = useState("") + const [showPin, setShowPin] = useState(false) + const [pinError, setPinError] = useState("") + const [showPinSetup, setShowPinSetup] = useState(false) + + const [createSharedWallet, { isLoading: isCreating }] = useCreateSharedWalletMutation() + const { data: contactsData, isLoading: isLoadingContacts } = useGetAcceptedContactsQuery(token!, { + skip: !token || !isOpen, + }) + + const contacts: Contact[] = contactsData?.contacts || [] + + useEffect(() => { + if (isOpen) { + setStep(1) + setWalletName("") + setSelectedContacts([]) + setWithdrawalPolicy("approval") + setPin("") + setPinError("") + } + }, [isOpen]) + + const toggleContact = (userId: string) => { + setSelectedContacts((prev) => + prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId] + ) + } + + const canProceedToPin = walletName.trim().length >= 2 && selectedContacts.length >= 1 + + const handleSubmit = async () => { + if (pin.length !== 4) { + setPinError("Enter your 4-digit PIN") + return + } + setPinError("") + + try { + await createSharedWallet({ + name: walletName.trim(), + withdrawalPolicy, + memberIds: selectedContacts, + pin, + }).unwrap() + + toast({ title: "Shared wallet created", description: `${walletName.trim()} is ready — RWF 0 to start.` }) + onClose() + } catch (error: any) { + const errorData = error?.data || {} + if (errorData.requiresPinSetup) { + setShowPinSetup(true) + toast({ + title: "PIN Setup Required", + description: errorData.message || "Please set up your transaction PIN first", + variant: "destructive", + }) + } else { + setPinError(errorData.message || "Failed to create shared wallet") + } + setPin("") + } + } + + const handlePinSetupSuccess = () => { + toast({ title: "PIN Setup Complete", description: "You can now create a shared wallet." }) + setShowPinSetup(false) + } + + return ( + <> + + + !open && onClose()}> + + +
+
+ +
+ Create Shared Wallet +
+ + A wallet 2 or more people can put money into and take it out of - no group chat needed + +
+ + {step === 1 ? ( +
+
+ + setWalletName(e.target.value)} + /> +
+ +
+ + setWithdrawalPolicy(value)}> +
+ + +
+
+ + +
+
+

This can't be changed after the wallet is created.

+
+ +
+ +
+ + Members ({selectedContacts.length} selected) +
+ + {isLoadingContacts ? ( +
+ + Loading contacts... +
+ ) : contacts.length === 0 ? ( +
No contacts available. Add some contacts first.
+ ) : ( +
+ {contacts.map((contact) => ( +
+ toggleContact(contact.otherUser.id)} + className="mr-2" + /> + +
+ ))} +
+ )} +
+
+ ) : ( +
+ +
+ )} + + + {step === 1 ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ + ) +} diff --git a/components/Wallet/DepositIntoSharedWalletModal.tsx b/components/Wallet/DepositIntoSharedWalletModal.tsx new file mode 100644 index 0000000..4ffc8a4 --- /dev/null +++ b/components/Wallet/DepositIntoSharedWalletModal.tsx @@ -0,0 +1,155 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Loader2 } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import PinEntry from "@/components/transfer/PinEntry" +import { PinSetupModal } from "@/components/PinSetupModal" +import { useDepositIntoSharedWalletMutation } from "@/states/sharedWalletSlice" + +interface DepositIntoSharedWalletModalProps { + isOpen: boolean + onClose: () => void + sharedWalletId: string + walletName: string + currency: string +} + +export default function DepositIntoSharedWalletModal({ + isOpen, + onClose, + sharedWalletId, + walletName, + currency, +}: DepositIntoSharedWalletModalProps) { + const [step, setStep] = useState<1 | 2>(1) + const [amount, setAmount] = useState("") + const [note, setNote] = useState("") + const [pin, setPin] = useState("") + const [showPin, setShowPin] = useState(false) + const [pinError, setPinError] = useState("") + const [showPinSetup, setShowPinSetup] = useState(false) + + const [deposit, { isLoading: depositing }] = useDepositIntoSharedWalletMutation() + + useEffect(() => { + if (isOpen) { + setStep(1) + setAmount("") + setNote("") + setPin("") + setPinError("") + } + }, [isOpen]) + + const parsedAmount = parseFloat(amount) + const canProceedToPin = !isNaN(parsedAmount) && parsedAmount > 0 + + const handleSubmit = async () => { + if (pin.length !== 4) { + setPinError("Enter your 4-digit PIN") + return + } + setPinError("") + + try { + await deposit({ sharedWalletId, amount: parsedAmount, pin, note: note || undefined }).unwrap() + toast({ title: "Deposit complete", description: `${parsedAmount.toLocaleString()} ${currency} added to ${walletName}.` }) + onClose() + } catch (error: any) { + const errorData = error?.data || {} + if (errorData.requiresPinSetup) { + setShowPinSetup(true) + toast({ + title: "PIN Setup Required", + description: errorData.message || "Please set up your transaction PIN first", + variant: "destructive", + }) + } else { + setPinError(errorData.message || "Something went wrong") + } + setPin("") + } + } + + const handlePinSetupSuccess = () => { + toast({ title: "PIN Setup Complete", description: "You can now deposit." }) + setShowPinSetup(false) + } + + return ( + <> + + + !open && onClose()}> + + + Deposit into {walletName} + + Money moves from your personal wallet immediately - no approval needed to add funds. + + + + {step === 1 ? ( +
+
+ + setAmount(e.target.value)} + autoFocus + /> +
+
+ + setNote(e.target.value)} + /> +
+
+ ) : ( +
+ +
+ )} + + + {step === 1 ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ + ) +} diff --git a/components/Wallet/SharedWalletDetail.tsx b/components/Wallet/SharedWalletDetail.tsx new file mode 100644 index 0000000..b89e90c --- /dev/null +++ b/components/Wallet/SharedWalletDetail.tsx @@ -0,0 +1,377 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { Card } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + WalletCards, Lock, ArrowUpFromLine, ArrowDownToLine, MessageCircle, + Loader2, CheckCircle, Ban, Clock, Users, UserMinus, UserPlus, LogOut, +} from "lucide-react" +import { useAccent } from "@/hooks/use-accent" +import { toast } from "@/hooks/use-toast" +import { getCurrentUserId } from "@/utils/tokenUtils" +import { + useGetSharedWalletByIdQuery, + useGetSharedWalletActivityQuery, + useGetSharedWalletMembersQuery, + useRemoveSharedWalletMemberMutation, + useLeaveSharedWalletMutation, + useApproveSharedWalletWithdrawalMutation, + useDeclineSharedWalletWithdrawalMutation, + useCancelSharedWalletWithdrawalMutation, +} from "@/states/sharedWalletSlice" +import socketService from "@/services/socketService" +import WithdrawFromSharedWalletModal from "@/components/Wallet/WithdrawFromSharedWalletModal" +import DepositIntoSharedWalletModal from "@/components/Wallet/DepositIntoSharedWalletModal" +import AddSharedWalletMemberModal from "@/components/Wallet/AddSharedWalletMemberModal" + +function fmtDate(ts: string) { + return new Date(ts).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) +} + +function PendingWithdrawalRow({ sharedWalletId, entry, onChanged }: { sharedWalletId: string; entry: any; onChanged: () => void }) { + const currentUserId = getCurrentUserId() + const isRequester = entry.requestedByUserId === currentUserId + const [showPin, setShowPin] = useState(false) + const [pin, setPin] = useState("") + + const [approve, { isLoading: approving }] = useApproveSharedWalletWithdrawalMutation() + const [decline, { isLoading: declining }] = useDeclineSharedWalletWithdrawalMutation() + const [cancel, { isLoading: cancelling }] = useCancelSharedWalletWithdrawalMutation() + + const handleApprove = async () => { + if (pin.length !== 4) return + try { + await approve({ sharedWalletId, withdrawalId: entry.id, pin }).unwrap() + toast({ title: "Vote recorded" }) + setShowPin(false) + setPin("") + onChanged() + } catch (err: any) { + toast({ title: "Could not approve", description: err?.data?.message, variant: "destructive" }) + setPin("") + } + } + + const handleDecline = async () => { + try { + await decline({ sharedWalletId, withdrawalId: entry.id }).unwrap() + toast({ title: "Vote recorded" }) + onChanged() + } catch (err: any) { + toast({ title: "Could not decline", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleCancel = async () => { + try { + await cancel({ sharedWalletId, withdrawalId: entry.id }).unwrap() + toast({ title: "Request cancelled" }) + onChanged() + } catch (err: any) { + toast({ title: "Could not cancel", description: err?.data?.message, variant: "destructive" }) + } + } + + return ( + +
+
+ + {entry.approveCount}/{entry.requiredApprovals} approved +
+ {entry.amount.toLocaleString()} {entry.currency} +
+

+ Requested by {isRequester ? "you" : (entry.requestedByName || "a member")} +

+ {entry.note &&

"{entry.note}"

} + {!isRequester && !showPin && ( +
+ + +
+ )} + {!isRequester && showPin && ( +
+ setPin(e.target.value.replace(/\D/g, ""))} + className="h-8 text-center" + /> + +
+ )} + {isRequester && ( + + )} +
+ ) +} + +export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: string }) { + const router = useRouter() + const accent = useAccent() + const currentUserId = getCurrentUserId() + + const { data: walletResp, isLoading, refetch } = useGetSharedWalletByIdQuery(sharedWalletId) + const { data: activityResp, refetch: refetchActivity } = useGetSharedWalletActivityQuery(sharedWalletId) + const { data: membersResp, refetch: refetchMembers } = useGetSharedWalletMembersQuery(sharedWalletId) + const [removeMember] = useRemoveSharedWalletMemberMutation() + const [leaveWallet, { isLoading: leaving }] = useLeaveSharedWalletMutation() + + const [showWithdraw, setShowWithdraw] = useState(false) + const [showDeposit, setShowDeposit] = useState(false) + const [showAddMember, setShowAddMember] = useState(false) + const [removeTarget, setRemoveTarget] = useState<{ userId: string; userName: string } | null>(null) + const [removingMember, setRemovingMember] = useState(false) + + const wallet = walletResp?.data + const activity: any[] = activityResp?.data ?? [] + const members: any[] = membersResp?.data ?? [] + const myMembership = members.find((m) => m.userId === currentUserId) + const canManageMembers = myMembership?.role === "owner" || myMembership?.role === "admin" + + useEffect(() => { + const onBalance = (data: { sharedWalletId: string }) => { + if (data.sharedWalletId === sharedWalletId) { + refetch() + refetchActivity() + } + } + const onWithdrawal = (data: { sharedWalletId: string }) => { + if (data.sharedWalletId === sharedWalletId) refetchActivity() + } + socketService.onSharedWalletBalanceUpdate(onBalance) + socketService.onSharedWalletWithdrawalUpdated(onWithdrawal) + return () => { + socketService.offSharedWalletBalanceUpdate(onBalance) + socketService.offSharedWalletWithdrawalUpdated(onWithdrawal) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sharedWalletId]) + + if (isLoading || !wallet) { + return ( +
+ +
+ ) + } + + const handleLeave = async () => { + try { + await leaveWallet(sharedWalletId).unwrap() + toast({ title: "Left the shared wallet" }) + router.push("/wallet/" + currentUserId) + } catch (err: any) { + toast({ title: "Could not leave", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleConfirmRemove = async () => { + if (!removeTarget) return + setRemovingMember(true) + try { + await removeMember({ sharedWalletId, userId: removeTarget.userId }).unwrap() + toast({ title: "Member removed" }) + setRemoveTarget(null) + } catch (err: any) { + toast({ title: "Could not remove member", description: err?.data?.message, variant: "destructive" }) + } finally { + setRemovingMember(false) + } + } + + return ( + <> +
+
+ +
+
+

{wallet.name}

+
+ {wallet.memberCount} {wallet.memberCount === 1 ? "member" : "members"} + {wallet.withdrawalPolicy === "approval" && ( + Approval required + )} +
+
+
+ + +

Balance

+

+ {wallet.balance.toLocaleString()} {wallet.currency} +

+
+ + +
+ {wallet.groupId && ( + + )} +
+ + + + Activity + Members ({members.length}) + + + + {activity.length === 0 ? ( +

No activity yet.

+ ) : ( + activity.map((entry) => + entry.kind === "withdrawal_request" ? ( + { refetchActivity(); refetch(); }} + /> + ) : ( + +
+ {entry.type === "deposit" ? ( + + ) : ( + + )} +
+

+ {entry.personName + ? `${entry.type === "deposit" ? "Deposited by" : "Withdrawn by"} ${entry.personName}` + : entry.description || (entry.type === "deposit" ? "Deposit" : "Withdrawal")} +

+

{fmtDate(entry.createdAt)}

+
+
+ + {entry.type === "deposit" ? "+" : "-"}{entry.amount.toLocaleString()} {entry.currency} + +
+ ) + ) + )} +
+ + + {!wallet.groupId && canManageMembers && ( + + )} + {members.map((m) => ( + +
+ +
+

{m.userName}

+

{m.role}

+
+
+ {canManageMembers && !wallet.groupId && m.role !== "owner" && m.userId !== currentUserId && ( + + )} +
+ ))} + {!wallet.groupId && myMembership && myMembership.role !== "owner" && ( + + )} +
+
+ + !open && setRemoveTarget(null)}> + + +
+
+ +
+ Remove member +
+ + Remove {removeTarget?.userName} from "{wallet.name}"? + They'll lose access to this wallet immediately. + +
+ + Cancel + { e.preventDefault(); handleConfirmRemove(); }} + disabled={removingMember} + className="bg-red-600 hover:bg-red-700 text-white" + > + {removingMember ? : null} + Remove + + +
+
+ + setShowAddMember(false)} + sharedWalletId={sharedWalletId} + existingMemberIds={members.map((m) => m.userId)} + /> + + setShowWithdraw(false)} + sharedWalletId={sharedWalletId} + walletName={wallet.name} + balance={wallet.balance} + currency={wallet.currency} + withdrawalPolicy={wallet.withdrawalPolicy} + /> + setShowDeposit(false)} + sharedWalletId={sharedWalletId} + walletName={wallet.name} + currency={wallet.currency} + /> + + ) +} diff --git a/components/Wallet/SharedWallets.tsx b/components/Wallet/SharedWallets.tsx new file mode 100644 index 0000000..4f3584b --- /dev/null +++ b/components/Wallet/SharedWallets.tsx @@ -0,0 +1,117 @@ +"use client"; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { WalletCards, ChevronRight, Lock, ArrowUpFromLine, Plus } from 'lucide-react'; +import { useGetMySharedWalletsQuery } from '@/states/sharedWalletSlice'; +import { useAccent } from '@/hooks/use-accent'; +import WithdrawFromSharedWalletModal from '@/components/Wallet/WithdrawFromSharedWalletModal'; +import CreateSharedWalletModal from '@/components/Wallet/CreateSharedWalletModal'; + +interface SharedWallet { + id: string; + name: string; + groupId?: string | null; + memberCount: number; + withdrawalPolicy?: 'free' | 'approval'; + balance: number; + currency: string; +} + +export const SharedWallets: React.FC = () => { + const router = useRouter(); + const accent = useAccent(); + const { data, isLoading } = useGetMySharedWalletsQuery(undefined); + const [withdrawTarget, setWithdrawTarget] = useState(null); + const [showCreate, setShowCreate] = useState(false); + + const wallets: SharedWallet[] = data?.data ?? []; + + if (isLoading) return null; + + const cardBase = `bg-white ${accent.darkBgCard} border border-gray-100 dark:border-white/[0.06]`; + + return ( +
+
+
+ +

Shared wallets

+ {wallets.length > 0 && ( + + {wallets.length} + + )} +
+ +
+ + {wallets.length === 0 ? ( +
+

+ Pool money with a few people — with or without a group chat. +

+
+ ) : ( +
+ {wallets.map((w) => ( +
router.push(`/wallets/shared/${w.id}`)} + className={`${cardBase} rounded-2xl p-3 flex items-center gap-3 cursor-pointer hover:shadow-md transition-all`} + > + + + +
+

{w.name}

+

+ {w.memberCount} {w.memberCount === 1 ? 'member' : 'members'} + {w.withdrawalPolicy === 'approval' && ( + + Approval required + + )} +

+
+ + {w.balance.toLocaleString()} {w.currency} + + + +
+ ))} +
+ )} + + {withdrawTarget && ( + setWithdrawTarget(null)} + sharedWalletId={withdrawTarget.id} + walletName={withdrawTarget.name} + balance={withdrawTarget.balance} + currency={withdrawTarget.currency} + withdrawalPolicy={withdrawTarget.withdrawalPolicy || 'approval'} + /> + )} + + setShowCreate(false)} /> +
+ ); +}; + +export default SharedWallets; diff --git a/components/Wallet/WalletView.tsx b/components/Wallet/WalletView.tsx index 43e51ed..a07a409 100644 --- a/components/Wallet/WalletView.tsx +++ b/components/Wallet/WalletView.tsx @@ -65,6 +65,7 @@ import QuickActions from '@/components/Wallet/QuickActions'; import RecentActivity from '@/components/Wallet/RecentActivity'; import SpendingInsights from '@/components/Wallet/SpendingInsights'; import Contributions from '@/components/Wallet/Contributions'; +import SharedWallets from '@/components/Wallet/SharedWallets'; import BudgetsPanel from '@/components/Wallet/BudgetsPanel'; import IncomingRulesPanel from '@/components/Wallet/IncomingRulesPanel'; @@ -417,12 +418,15 @@ export const WalletView: React.FC = ({ const { balanceBreakdown, restrictions, items } = summary; // Alerts: reserved budgets exceed balance, or nothing left to spend + const hasHeld = balanceBreakdown.held > 0; const overBudget = balanceBreakdown.restricted > balanceBreakdown.total; const lowBalance = balanceBreakdown.total > 0 && balanceBreakdown.available <= 0 && !overBudget; const alert = overBudget ? `Your category budgets (${balanceBreakdown.restricted.toLocaleString()} ${currency}) exceed your balance (${balanceBreakdown.total.toLocaleString()} ${currency}).` : lowBalance - ? 'All your funds are reserved by category budgets — nothing is freely available to spend.' + ? hasHeld + ? `Nothing is freely available to spend — ${balanceBreakdown.held.toLocaleString()} ${currency} is reserved by pending scheduled transfers/holds${balanceBreakdown.restricted > 0 ? ' and category budgets' : ''}.` + : 'All your funds are reserved by category budgets — nothing is freely available to spend.' : null; return ( @@ -465,7 +469,7 @@ export const WalletView: React.FC = ({ {showBalance ? money(balanceBreakdown.total, currency) : `${hidden} ${currency}`} -
+
Available @@ -482,6 +486,16 @@ export const WalletView: React.FC = ({ {showBalance ? money(balanceBreakdown.restricted, currency) : hidden}
+ {hasHeld && ( +
+
+ Held +
+
+ {showBalance ? money(balanceBreakdown.held, currency) : hidden} +
+
+ )}
{balanceBreakdown.total > 0 && ( @@ -758,7 +772,10 @@ export const WalletView: React.FC = ({ {/* Recent activity */} - {/* Shared wallets / contributions */} + {/* Shared wallets the user is a member of */} + + + {/* Fundraising contributions */}
diff --git a/components/Wallet/WithdrawFromSharedWalletModal.tsx b/components/Wallet/WithdrawFromSharedWalletModal.tsx new file mode 100644 index 0000000..254979f --- /dev/null +++ b/components/Wallet/WithdrawFromSharedWalletModal.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Loader2 } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import PinEntry from "@/components/transfer/PinEntry" +import { PinSetupModal } from "@/components/PinSetupModal" +import { + useWithdrawFromSharedWalletMutation, + useProposeSharedWalletWithdrawalMutation, +} from "@/states/sharedWalletSlice" + +interface WithdrawFromSharedWalletModalProps { + isOpen: boolean + onClose: () => void + sharedWalletId: string + walletName: string + balance: number + currency: string + withdrawalPolicy: 'free' | 'approval' +} + +export default function WithdrawFromSharedWalletModal({ + isOpen, + onClose, + sharedWalletId, + walletName, + balance, + currency, + withdrawalPolicy, +}: WithdrawFromSharedWalletModalProps) { + const [step, setStep] = useState<1 | 2>(1) + const [amount, setAmount] = useState("") + const [note, setNote] = useState("") + const [pin, setPin] = useState("") + const [showPin, setShowPin] = useState(false) + const [pinError, setPinError] = useState("") + const [showPinSetup, setShowPinSetup] = useState(false) + + const [withdrawFree, { isLoading: withdrawing }] = useWithdrawFromSharedWalletMutation() + const [proposeWithdrawal, { isLoading: proposing }] = useProposeSharedWalletWithdrawalMutation() + const submitting = withdrawing || proposing + + useEffect(() => { + if (isOpen) { + setStep(1) + setAmount("") + setNote("") + setPin("") + setPinError("") + } + }, [isOpen]) + + const parsedAmount = parseFloat(amount) + const canProceedToPin = !isNaN(parsedAmount) && parsedAmount > 0 && parsedAmount <= balance + + const handleSubmit = async () => { + if (pin.length !== 4) { + setPinError("Enter your 4-digit PIN") + return + } + setPinError("") + + try { + if (withdrawalPolicy === "free") { + await withdrawFree({ sharedWalletId, amount: parsedAmount, pin, note: note || undefined }).unwrap() + toast({ title: "Withdrawal complete", description: `RWF ${parsedAmount.toLocaleString()} moved to your wallet.` }) + } else { + await proposeWithdrawal({ sharedWalletId, amount: parsedAmount, pin, note: note || undefined }).unwrap() + toast({ title: "Withdrawal requested", description: "Waiting for another member to approve." }) + } + onClose() + } catch (error: any) { + const errorData = error?.data || {} + if (errorData.requiresPinSetup) { + setShowPinSetup(true) + toast({ + title: "PIN Setup Required", + description: errorData.message || "Please set up your transaction PIN first", + variant: "destructive", + }) + } else { + setPinError(errorData.message || "Something went wrong") + } + setPin("") + } + } + + const handlePinSetupSuccess = () => { + toast({ title: "PIN Setup Complete", description: "You can now withdraw." }) + setShowPinSetup(false) + } + + return ( + <> + + + !open && onClose()}> + + + Withdraw from {walletName} + + {withdrawalPolicy === "free" + ? "Money moves to your personal wallet immediately." + : "This will post a request in the chat — a majority of other members must approve before money moves."} + + + + {step === 1 ? ( +
+

+ Available: {balance.toLocaleString()} {currency} +

+
+ + setAmount(e.target.value)} + autoFocus + /> +
+
+ + setNote(e.target.value)} + /> +
+
+ ) : ( +
+ +
+ )} + + + {step === 1 ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ + ) +} diff --git a/components/chat/chat-area.tsx b/components/chat/chat-area.tsx index cb24d76..bff45cf 100644 --- a/components/chat/chat-area.tsx +++ b/components/chat/chat-area.tsx @@ -68,6 +68,8 @@ interface ChatAreaProps { onVerifySecurity?: () => void onCreateContribution?: () => void onCreateGroup?: () => void + onSendMultiple?: () => void + onCreateSharedWallet?: () => void isGroupAdmin?: boolean typingUsers?: any[] onlineUsers?: any[] @@ -87,6 +89,8 @@ export default function ChatArea({ onVerifySecurity, onCreateContribution, onCreateGroup, + onSendMultiple, + onCreateSharedWallet, isGroupAdmin = false, typingUsers = [], onlineUsers = [], @@ -202,6 +206,8 @@ export default function ChatArea({ onRequestMoney={onRequestMoney} onCreateContribution={onCreateContribution} onCreateGroup={onCreateGroup} + onSendMultiple={onSendMultiple} + onCreateSharedWallet={onCreateSharedWallet} /> diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index b73c2bb..d455a8f 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -16,6 +16,7 @@ import { useChat } from '@/context/ChatContext'; import { useGetGroupByIdQuery } from '@/states/groupSlice'; import { useAuthToken } from '@/hooks/use-auth-token'; import FundraisingProgressBadge from './fundraising-progress-badge'; +import SharedWalletBalanceBadge from './shared-wallet-balance-badge'; import { socketService } from '@/services/socketService'; import { getInitials, isPlaceholderAvatar } from '@/utils/avatar'; import { @@ -123,6 +124,23 @@ export default function ChatHeader({ }; }, [conversation.isGroup, conversation.groupId, refetchGroupData]); + // Listen for real-time shared wallet balance updates (deposit or completed withdrawal) + useEffect(() => { + if (!conversation.isGroup || !conversation.groupId) return; + + const handleBalanceUpdate = (data: any) => { + if (data.groupId === conversation.groupId) { + refetchGroupData(); + } + }; + + socketService.onSharedWalletBalanceUpdate(handleBalanceUpdate); + + return () => { + socketService.offSharedWalletBalanceUpdate(handleBalanceUpdate); + }; + }, [conversation.isGroup, conversation.groupId, refetchGroupData]); + const getOnlineStatus = () => { if (conversation.isGroup) { return `${conversation.isOnline ? 1 : 0} online`; @@ -195,8 +213,17 @@ export default function ChatHeader({ )} - {/* Online Status (only show if not fundraising) */} - {!(conversation.isGroup && group?.hasFundraising) && ( + {/* Shared Wallet Balance for Groups (fundraising badge takes priority if both are somehow set) */} + {conversation.isGroup && + !group?.hasFundraising && + group?.sharedWalletId && ( +
+ +
+ )} + + {/* Online Status (only show if not fundraising/shared wallet) */} + {!(conversation.isGroup && (group?.hasFundraising || group?.sharedWalletId)) && (

{getOnlineStatus()}

diff --git a/components/chat/chat-page-clean.tsx b/components/chat/chat-page-clean.tsx index 1528dd9..2f40d59 100644 --- a/components/chat/chat-page-clean.tsx +++ b/components/chat/chat-page-clean.tsx @@ -24,6 +24,8 @@ 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 SendMoneyBatchModal from '@/components/chat/send-money-batch-modal'; +import CreateSharedWalletModal from '@/components/chat/create-shared-wallet-modal'; import { getGroupById } from '@/helpers/api'; import AddContactModal from '@/components/chat/add-contact-modal'; import UserProfileModal from '@/components/chat/user-profile-modal'; @@ -87,6 +89,10 @@ export default function ChatPageClean() { useState(false); const [isCreateGroupModalOpen, setIsCreateGroupModalOpen] = useState(false); + const [isSendMultipleModalOpen, setIsSendMultipleModalOpen] = + useState(false); + const [isCreateSharedWalletModalOpen, setIsCreateSharedWalletModalOpen] = + useState(false); const [selectedOutsideMessage, setSelectedOutsideMessage] = useState(null); const [isGroupSettingsModalOpen, setIsGroupSettingsModalOpen] = @@ -408,6 +414,8 @@ export default function ChatPageClean() { setIsCreateContributionModalOpen(true) } onCreateGroup={() => setIsCreateGroupModalOpen(true)} + onSendMultiple={() => setIsSendMultipleModalOpen(true)} + onCreateSharedWallet={() => setIsCreateSharedWalletModalOpen(true)} isGroupAdmin={isGroupAdmin} onViewProfile={handleViewProfile} onInviteToGroup={handleInviteToGroup} @@ -471,6 +479,17 @@ export default function ChatPageClean() { token={token} /> + setIsSendMultipleModalOpen(false)} + /> + + setIsCreateSharedWalletModalOpen(false)} + token={token} + /> + setIsAddContactModalOpen(false)} diff --git a/components/chat/create-shared-wallet-modal.tsx b/components/chat/create-shared-wallet-modal.tsx new file mode 100644 index 0000000..53cf18b --- /dev/null +++ b/components/chat/create-shared-wallet-modal.tsx @@ -0,0 +1,270 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Separator } from "@/components/ui/separator" +import { Input } from "@/components/ui/input" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "../ui/label" +import { WalletCards, Users, Loader2 } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import { useCreateGroupMutation } from "@/states/groupSlice" +import { useGetAcceptedContactsQuery } from "@/states/contactSlice" +import PinEntry from "@/components/transfer/PinEntry" +import { PinSetupModal } from "@/components/PinSetupModal" + +interface CreateSharedWalletModalProps { + isOpen: boolean + onClose: () => void + token: string | null +} + +interface Contact { + id: string + otherUser: { + id: string + firstName: string + lastName: string + email: string + avatar?: string + } +} + +type WithdrawalPolicy = "free" | "approval" + +export default function CreateSharedWalletModal({ isOpen, onClose, token }: CreateSharedWalletModalProps) { + const [step, setStep] = useState<1 | 2>(1) + const [walletName, setWalletName] = useState("") + const [selectedContacts, setSelectedContacts] = useState([]) + const [withdrawalPolicy, setWithdrawalPolicy] = useState("approval") + const [pin, setPin] = useState("") + const [showPin, setShowPin] = useState(false) + const [pinError, setPinError] = useState("") + const [showPinSetup, setShowPinSetup] = useState(false) + + const [createGroup, { isLoading: isCreating }] = useCreateGroupMutation() + const { data: contactsData, isLoading: isLoadingContacts } = useGetAcceptedContactsQuery(token!, { + skip: !token || !isOpen, + }) + + const contacts: Contact[] = contactsData?.contacts || [] + + useEffect(() => { + if (isOpen) { + setStep(1) + setWalletName("") + setSelectedContacts([]) + setWithdrawalPolicy("approval") + setPin("") + setPinError("") + } + }, [isOpen]) + + const toggleContact = (userId: string) => { + setSelectedContacts((prev) => + prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId] + ) + } + + const canProceedToPin = walletName.trim().length >= 2 && selectedContacts.length >= 1 + + const handleClose = () => { + onClose() + } + + const handleSubmit = async () => { + if (pin.length !== 4) { + setPinError("Enter your 4-digit PIN") + return + } + if (!token) { + toast({ title: "Error", description: "You must be logged in to create a shared wallet", variant: "destructive" }) + return + } + + setPinError("") + + try { + const result = await createGroup({ + groupData: { + name: walletName.trim(), + privacyType: "private", + hasFundraising: false, + hasSharedWallet: true, + withdrawalPolicy, + memberIds: selectedContacts, + pin, + }, + token, + }).unwrap() + + toast({ title: "Shared wallet created", description: result.message || `${walletName.trim()} is ready — RWF 0 to start.` }) + handleClose() + } catch (error: any) { + const errorData = error?.data || {} + if (errorData.requiresPinSetup) { + setShowPinSetup(true) + toast({ + title: "PIN Setup Required", + description: errorData.message || "Please set up your transaction PIN first", + variant: "destructive", + }) + } else { + setPinError(errorData.message || "Failed to create shared wallet") + } + setPin("") + } + } + + const handlePinSetupSuccess = () => { + toast({ title: "PIN Setup Complete", description: "You can now create a shared wallet." }) + setShowPinSetup(false) + } + + return ( + <> + + + !open && handleClose()}> + + +
+
+ +
+ Create Shared Wallet +
+ + A wallet 2 or more people can put money into and take it out of + +
+ + {step === 1 ? ( +
+
+ + setWalletName(e.target.value)} + /> +
+ +
+ + setWithdrawalPolicy(value)}> +
+ + +
+
+ + +
+
+

This can't be changed after the wallet is created.

+
+ +
+ +
+ + Members ({selectedContacts.length} selected) +
+ + {isLoadingContacts ? ( +
+ + Loading contacts... +
+ ) : contacts.length === 0 ? ( +
No contacts available. Add some contacts first.
+ ) : ( +
+ {contacts.map((contact) => ( +
+ toggleContact(contact.otherUser.id)} + className="mr-2" + /> + +
+ ))} +
+ )} +
+
+ ) : ( +
+ +
+ )} + + + {step === 1 ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ + ) +} diff --git a/components/chat/escrow-message-card.tsx b/components/chat/escrow-message-card.tsx index a1c4acd..ae6a07d 100644 --- a/components/chat/escrow-message-card.tsx +++ b/components/chat/escrow-message-card.tsx @@ -9,7 +9,9 @@ import { useGetEscrowByIdQuery, useReleaseEscrowMutation, useFulfillEscrowMutation, - useRefundEscrowMutation, + useProposeSettlementMutation, + useAcceptSettlementMutation, + useDeclineSettlementMutation, useDisputeEscrowMutation, } from "@/states/escrowSlice"; import { toast } from "@/hooks/use-toast"; @@ -59,17 +61,25 @@ export const EscrowMessageCard: React.FC = ({ data, isMe const [releaseEscrow, { isLoading: releasing }] = useReleaseEscrowMutation(); const [fulfillEscrow, { isLoading: fulfilling }] = useFulfillEscrowMutation(); - const [refundEscrow, { isLoading: refunding }] = useRefundEscrowMutation(); + const [proposeSettlement, { isLoading: proposing }] = useProposeSettlementMutation(); + const [acceptSettlement, { isLoading: accepting }] = useAcceptSettlementMutation(); + const [declineSettlement, { isLoading: declining }] = useDeclineSettlementMutation(); const [disputeEscrow, { isLoading: disputing }] = useDisputeEscrowMutation(); - const [showDisputeForm, setShowDisputeForm] = React.useState(false); + const [activeForm, setActiveForm] = React.useState(null); const [disputeReason, setDisputeReason] = React.useState(""); + const [proposeAmount, setProposeAmount] = React.useState(String(data.amount)); + const [proposeNote, setProposeNote] = 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 isFulfilled = Boolean(escrow?.fulfilledAt); + const hasProposal = Boolean(escrow?.proposedByUserId); + const isProposer = hasProposal && escrow?.proposedByUserId === currentUserId; + const proposerName = escrow?.proposedByUserId === escrow?.payerUserId ? data.payerName : data.payeeName; + const otherName = isPayer ? data.payeeName : data.payerName; const handleRelease = async () => { try { @@ -89,12 +99,36 @@ export const EscrowMessageCard: React.FC = ({ data, isMe } }; - const handleRefund = async () => { + const handlePropose = async () => { + const amount = Number(proposeAmount); + if (isNaN(amount) || amount < 0 || amount > data.amount) { + toast({ title: "Invalid amount", description: `Enter an amount between 0 and ${fmt(data.amount, data.currency)}`, variant: "destructive" }); + return; + } + try { + await proposeSettlement({ escrowId: data.escrowId, chatId, payeeAmount: amount, note: proposeNote || undefined }).unwrap(); + toast({ title: "Proposal sent", description: `Waiting for ${otherName} to respond` }); + setActiveForm(null); + } catch (err: any) { + toast({ title: "Could not propose", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + + const handleAccept = async () => { + try { + await acceptSettlement({ escrowId: data.escrowId, chatId }).unwrap(); + toast({ title: "Settlement accepted", description: "Funds have been split as agreed" }); + } catch (err: any) { + toast({ title: "Could not accept", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + + const handleDecline = async () => { try { - await refundEscrow({ escrowId: data.escrowId, chatId }).unwrap(); - toast({ title: "Escrow cancelled", description: `${fmt(data.amount, data.currency)} returned to your balance` }); + await declineSettlement({ escrowId: data.escrowId, chatId }).unwrap(); + toast({ title: "Proposal declined" }); } catch (err: any) { - toast({ title: "Could not cancel", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + toast({ title: "Could not decline", description: err?.data?.message || "Something went wrong", variant: "destructive" }); } }; @@ -106,7 +140,7 @@ export const EscrowMessageCard: React.FC = ({ data, isMe try { await disputeEscrow({ escrowId: data.escrowId, chatId, reason: disputeReason }).unwrap(); toast({ title: "Dispute raised", description: "An admin will review this escrow" }); - setShowDisputeForm(false); + setActiveForm(null); } catch (err: any) { toast({ title: "Could not raise dispute", description: err?.data?.message || "Something went wrong", variant: "destructive" }); } @@ -118,6 +152,11 @@ export const EscrowMessageCard: React.FC = ({ data, isMe Released ); + if (status === "settled") return ( + + Settled + + ); if (status === "refunded") return ( Refunded @@ -142,7 +181,7 @@ export const EscrowMessageCard: React.FC = ({ data, isMe const shell = ` w-[260px] rounded-2xl overflow-hidden shadow-sm border - ${status === "released" + ${status === "released" || status === "settled" ? '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' @@ -154,7 +193,7 @@ export const EscrowMessageCard: React.FC = ({ data, isMe } `.trim(); - const stripColor = status === "released" + const stripColor = status === "released" || status === "settled" ? 'bg-green-500' : status === "disputed" ? 'bg-red-500' @@ -216,9 +255,9 @@ export const EscrowMessageCard: React.FC = ({ data, isMe
- {!showDisputeForm ? ( + {activeForm === null ? ( <> - {isFulfilled && ( + {isFulfilled && !hasProposal && (

{isPayee @@ -231,32 +270,85 @@ export const EscrowMessageCard: React.FC = ({ data, isMe

)} - {isPayer && ( -
- {!isFulfilled && ( - + {hasProposal ? ( +
+
+

+ {isProposer ? "Your proposal: " : `${proposerName} proposed: `} + {fmt(Number(escrow.proposedPayeeAmount), data.currency)} to {data.payeeName}, {fmt(Number(escrow.proposedPayerAmount), data.currency)} back to {data.payerName} +

+ {escrow.proposedNote && ( +

"{escrow.proposedNote}"

+ )} +
+ {isProposer ? ( +

Waiting for {otherName} to respond

+ ) : ( +
+ + +
)} -
- )} + ) : ( + <> + {isPayer && ( + + )} - {isPayee && !isFulfilled && ( - + {isPayee && !isFulfilled && ( + + )} + + + )} + ) : activeForm === "propose" ? ( +
+

Amount to send to {data.payeeName} (rest returns to {data.payerName}):

+ setProposeAmount(e.target.value)} + className="h-8 text-xs" + autoFocus + /> + setProposeNote(e.target.value)} + className="h-8 text-xs" + /> +
+ + +
+
) : (
= ({ data, isMe autoFocus />
- + diff --git a/components/chat/message-input.tsx b/components/chat/message-input.tsx index 42e0e54..2af0def 100644 --- a/components/chat/message-input.tsx +++ b/components/chat/message-input.tsx @@ -56,6 +56,8 @@ interface MessageInputProps { onRequestMoney?: () => void onCreateContribution?: () => void onCreateGroup?: () => void + onSendMultiple?: () => void + onCreateSharedWallet?: () => void } export default function MessageInput({ @@ -70,6 +72,8 @@ export default function MessageInput({ onRequestMoney, onCreateContribution, onCreateGroup, + onSendMultiple, + onCreateSharedWallet, }: MessageInputProps) { const [messageText, setMessageText] = useState("") const [showOptions, setShowOptions] = useState(false) @@ -412,6 +416,16 @@ export default function MessageInput({ toast({ title: "Unavailable", description: "Unable to open group creation right now", variant: "destructive" }) return } + if (option === "Send to Multiple") { + if (onSendMultiple) { onSendMultiple(); return } + toast({ title: "Unavailable", description: "Unable to open multi-send right now", variant: "destructive" }) + return + } + if (option === "Shared Wallet") { + if (onCreateSharedWallet) { onCreateSharedWallet(); return } + toast({ title: "Unavailable", description: "Unable to open shared wallet creation right now", variant: "destructive" }) + return + } toast({ title: "Coming soon", description: option }) } diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index 33856df..780ded0 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -5,6 +5,7 @@ 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 { SharedWalletWithdrawalCard } from "./shared-wallet-withdrawal-card" import { GroupContributionCard } from "./group-contribution-card" import MessageText from "./message-text" import LinkPreviewCard from "./link-preview-card" @@ -67,6 +68,7 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { let moneyTransferData = null; let groupContributionData = null; let escrowData = null; + let sharedWalletWithdrawalData = null; let isOldMoneyMessage = false; // Parse escrow hold data @@ -85,13 +87,20 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { if (isMoneyMessage && messageContent) { try { const parsed = JSON.parse(messageContent); - const isTransferLike = (parsed.type === 'money_transfer' || parsed.type === 'group_donation') && parsed.transactionId && parsed.amount; + const isTransferLike = + (parsed.type === 'money_transfer' || parsed.type === 'group_donation' || + parsed.type === 'shared_wallet_deposit' || parsed.type === 'shared_wallet_withdrawal') && + parsed.transactionId && parsed.amount; const isRequestLike = parsed.type === 'money_request' && parsed.requestId && parsed.amount; const isContributionLike = parsed.type === 'group_contribution' && parsed.contributionId && parsed.groupId; + const isScheduledTransferLike = parsed.type === 'scheduled_transfer' && parsed.scheduledTransferId && parsed.amount; + const isSharedWalletWithdrawalRequestLike = parsed.type === 'shared_wallet_withdrawal_request' && parsed.withdrawalId && parsed.amount; if (isContributionLike) { groupContributionData = parsed; - } else if (isTransferLike || isRequestLike) { + } else if (isSharedWalletWithdrawalRequestLike) { + sharedWalletWithdrawalData = parsed; + } else if (isTransferLike || isRequestLike || isScheduledTransferLike) { moneyTransferData = parsed; } else { // Old format - just has note text @@ -103,7 +112,7 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { } } - if (!isMediaMessage && !moneyTransferData && !escrowData) { + if (!isMediaMessage && !moneyTransferData && !escrowData && !sharedWalletWithdrawalData) { if (typeof messageContent === 'object' && messageContent !== null) { if (messageContent.content) { messageContent = messageContent.content; @@ -348,6 +357,15 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { ); } + // Render shared wallet withdrawal request as a special card + if (isMoneyMessage && sharedWalletWithdrawalData) { + return ( +
+ +
+ ); + } + return (
{shouldShowIncomingAvatar && ( diff --git a/components/chat/money-message-card.tsx b/components/chat/money-message-card.tsx index 25a2236..2ef7245 100644 --- a/components/chat/money-message-card.tsx +++ b/components/chat/money-message-card.tsx @@ -3,7 +3,8 @@ import React from "react"; import { Download, CheckCircle, ArrowRight, Heart, TrendingUp, - Lock, Pencil, X, CircleDollarSign, Clock, Ban + Lock, Pencil, X, CircleDollarSign, Clock, Ban, CalendarClock, + ArrowDownToLine, ArrowUpFromLine } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,7 +16,7 @@ import { toast } from "@/hooks/use-toast"; // ─── Types ──────────────────────────────────────────────────────────────────── interface MoneyTransferData { - type: 'money_transfer' | 'group_donation'; + type: 'money_transfer' | 'group_donation' | 'shared_wallet_deposit' | 'shared_wallet_withdrawal'; amount: number; currency: string; senderName: string; @@ -45,8 +46,21 @@ interface MoneyRequestData { timestamp: string; } +interface ScheduledTransferData { + type: 'scheduled_transfer'; + scheduledTransferId: string; + amount: number; + currency: string; + senderName: string; + recipientName: string; + scheduledFor: string; + status: 'scheduled' | 'completed' | 'cancelled' | 'failed'; + transactionId?: string | null; + timestamp: string; +} + interface MoneyMessageCardProps { - data: MoneyTransferData | MoneyRequestData; + data: MoneyTransferData | MoneyRequestData | ScheduledTransferData; isMe: boolean; chatId?: string; } @@ -361,6 +375,9 @@ function MoneyRequestCard({ data, isMe, chatId }: { data: MoneyRequestData; isMe function MoneyTransferCard({ data, isMe }: { data: MoneyTransferData; isMe: boolean }) { const [downloadReceipt, { isLoading }] = useDownloadTransactionReceiptMutation(); const isGroupDonation = data.type === 'group_donation'; + const isSharedWalletDeposit = data.type === 'shared_wallet_deposit'; + const isSharedWalletWithdrawal = data.type === 'shared_wallet_withdrawal'; + const hasReceipt = data.type === 'money_transfer' || data.type === 'group_donation' || data.type === 'shared_wallet_deposit'; const handleDownload = async () => { try { @@ -382,18 +399,42 @@ function MoneyTransferCard({ data, isMe }: { data: MoneyTransferData; isMe: bool const accentColor = isGroupDonation ? 'bg-blue-500' - : 'bg-brand-green dark:bg-brand-gold'; + : isSharedWalletDeposit + ? 'bg-emerald-500' + : isSharedWalletWithdrawal + ? 'bg-amber-500' + : 'bg-brand-green dark:bg-brand-gold'; const shell = ` w-[260px] rounded-2xl overflow-hidden shadow-sm border ${isGroupDonation ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800/30' - : 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' + : isSharedWalletDeposit + ? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-100 dark:border-emerald-800/30' + : isSharedWalletWithdrawal + ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-100 dark:border-amber-800/30' + : 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 headerLabel = isGroupDonation + ? 'Donation' + : isSharedWalletDeposit + ? 'Added to Wallet' + : isSharedWalletWithdrawal + ? 'Wallet Withdrawal' + : 'Money Sent'; + + const amountColor = isGroupDonation + ? 'text-blue-600 dark:text-blue-300' + : isSharedWalletDeposit + ? 'text-emerald-600 dark:text-emerald-300' + : isSharedWalletWithdrawal + ? 'text-amber-600 dark:text-amber-300' + : 'text-brand-green dark:text-brand-gold'; + return (
{/* Accent strip */} @@ -404,10 +445,14 @@ function MoneyTransferCard({ data, isMe }: { data: MoneyTransferData; isMe: bool
{isGroupDonation ? - : + : isSharedWalletDeposit + ? + : isSharedWalletWithdrawal + ? + : } - {isGroupDonation ? 'Donation' : 'Money Sent'} + {headerLabel}
@@ -416,7 +461,7 @@ function MoneyTransferCard({ data, isMe }: { data: MoneyTransferData; isMe: bool
{/* Group badge */} - {isGroupDonation && data.groupName && ( + {(isGroupDonation || isSharedWalletDeposit || isSharedWalletWithdrawal) && data.groupName && (

{data.groupName}

@@ -425,7 +470,7 @@ function MoneyTransferCard({ data, isMe }: { data: MoneyTransferData; isMe: bool {/* Amount */}
-

+

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

@@ -457,20 +502,117 @@ function MoneyTransferCard({ data, isMe }: { data: MoneyTransferData; isMe: bool {data.referenceId} {fmtTime(data.timestamp)}
- + {hasReceipt && ( + + )} +
+
+ ); +} + +// ─── Scheduled Transfer Card ──────────────────────────────────────────────────── + +function ScheduledTransferCard({ data, isMe }: { data: ScheduledTransferData; isMe: boolean }) { + const { status } = data; + + const statusPill = () => { + if (status === 'completed') return ( + + Completed + + ); + if (status === 'cancelled') return ( + + Cancelled + + ); + if (status === 'failed') return ( + + Failed + + ); + return ( + + Scheduled + + ); + }; + + const shell = ` + w-[260px] rounded-2xl overflow-hidden shadow-sm border + ${status === 'completed' + ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800/40' + : status === 'failed' + ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800/40' + : status === 'cancelled' + ? '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 === 'completed' + ? 'bg-green-500' + : status === 'failed' + ? 'bg-red-500' + : status === 'cancelled' + ? 'bg-gray-400' + : 'bg-brand-green dark:bg-brand-gold'; + + return ( +
+
+ +
+
+ + + Scheduled Payment + +
+ {statusPill()}
+ +
+

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

+
+ +
+
+

From

+

{data.senderName}

+
+ +
+

To

+

{data.recipientName}

+
+
+ + {status === 'scheduled' && ( +

+ Sends on {fmtTime(data.scheduledFor)} +

+ )} + +

+ {fmtTime(data.timestamp)} +

); } @@ -481,5 +623,8 @@ export const MoneyMessageCard: React.FC = ({ data, isMe, if (data.type === 'money_request') { return ; } + if (data.type === 'scheduled_transfer') { + return ; + } return ; }; diff --git a/components/chat/options-dropdown.tsx b/components/chat/options-dropdown.tsx index 082e41f..75a6c2d 100644 --- a/components/chat/options-dropdown.tsx +++ b/components/chat/options-dropdown.tsx @@ -66,6 +66,12 @@ export default function OptionsDropdown({ color: 'text-amber-600 dark:text-amber-300', action: () => onOptionSelect('Request Money'), }, + { + icon: , + label: 'Send to multiple', + color: 'text-indigo-600 dark:text-indigo-300', + action: () => onOptionSelect('Send to Multiple'), + }, { icon: , label: 'Ticket', diff --git a/components/chat/request-money-modal.tsx b/components/chat/request-money-modal.tsx index 58a660d..12cb076 100644 --- a/components/chat/request-money-modal.tsx +++ b/components/chat/request-money-modal.tsx @@ -152,7 +152,7 @@ export default function RequestMoneyModal({ isOpen, onClose, conversation }: Req return ( <> !open && !isSubmitting && !createdRequestId && onClose()}> - +
@@ -214,12 +214,12 @@ export default function RequestMoneyModal({ isOpen, onClose, conversation }: Req : 'bg-gray-50 dark:bg-darkBg-interactive border-gray-200 dark:border-darkBorder-light' }`} > -
+
{allowEditAmount - ? - : + ? + : } -
+

{allowEditAmount ? 'Amount is negotiable' : 'Amount is fixed'}

@@ -228,7 +228,7 @@ export default function RequestMoneyModal({ isOpen, onClose, conversation }: Req

-
+
diff --git a/components/chat/send-money-batch-modal.tsx b/components/chat/send-money-batch-modal.tsx new file mode 100644 index 0000000..0409435 --- /dev/null +++ b/components/chat/send-money-batch-modal.tsx @@ -0,0 +1,336 @@ +"use client" + +import { useState, useEffect, useMemo } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Users, Loader2, CalendarClock, AlertCircle } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import { useAuthToken } from "@/hooks/use-auth-token" +import { useGetAcceptedContactsQuery } from "@/states/contactSlice" +import { useGetUserWalletBalanceQuery } from "@/states/chatSlice" +import { createBatchTransfer, createScheduledBatchTransfer } from "@/helpers/api" +import BatchRecipientRow, { BatchContact } from "@/components/transfer/BatchRecipientRow" +import BatchResultsList, { BatchResultEntry } from "@/components/transfer/BatchResultsList" +import PinEntry from "@/components/transfer/PinEntry" +import SchedulePicker, { ScheduleState, defaultScheduleState } from "@/components/transfer/SchedulePicker" +import { PinSetupModal } from "@/components/PinSetupModal" +import { formatCurrency } from "@/utils/currency" + +interface SendMoneyBatchModalProps { + isOpen: boolean + onClose: () => void +} + +export default function SendMoneyBatchModal({ isOpen, onClose }: SendMoneyBatchModalProps) { + const { getToken, getUserId } = useAuthToken(false) + const token = getToken() + const userId = getUserId() + + const { data: contactsData, isLoading: loadingContacts } = useGetAcceptedContactsQuery(token || "", { + skip: !token || !isOpen, + }) + const { data: walletData, refetch: refetchBalance } = useGetUserWalletBalanceQuery( + { userId: userId || "" }, + { skip: !userId || !isOpen } + ) + + const [step, setStep] = useState<1 | 2 | 3>(1) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [amounts, setAmounts] = useState>({}) + const [schedule, setSchedule] = useState(defaultScheduleState) + const [notifyRecipientNow, setNotifyRecipientNow] = useState(false) + const [pin, setPin] = useState("") + const [showPin, setShowPin] = useState(false) + const [pinError, setPinError] = useState("") + const [submitting, setSubmitting] = useState(false) + const [showPinSetup, setShowPinSetup] = useState(false) + const [results, setResults] = useState([]) + + const walletBalance = walletData?.data?.balance ?? null + + useEffect(() => { + if (isOpen) { + setStep(1) + setSelectedIds(new Set()) + setAmounts({}) + setSchedule(defaultScheduleState) + setNotifyRecipientNow(false) + setPin("") + setPinError("") + setResults([]) + if (userId) refetchBalance() + } + }, [isOpen, userId, refetchBalance]) + + const contacts: BatchContact[] = useMemo( + () => + (contactsData?.contacts || []).map((c) => ({ + id: c.otherUser.id, + name: `${c.otherUser.firstName} ${c.otherUser.lastName}`.trim(), + phone: c.otherUser.phone || "", + avatar: c.otherUser.profile?.profileImage || null, + })), + [contactsData] + ) + + const contactsById = useMemo(() => { + const map: Record = {} + for (const c of contacts) map[c.id] = c + return map + }, [contacts]) + + const toggleRecipient = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const totalAmount = Array.from(selectedIds).reduce((sum, id) => sum + (parseFloat(amounts[id]) || 0), 0) + + const canProceedToPin = + selectedIds.size > 0 && + Array.from(selectedIds).every((id) => parseFloat(amounts[id]) > 0) && + (walletBalance === null || totalAmount <= walletBalance) && + (!schedule.enabled || (schedule.date && schedule.time)) + + const buildRecurrence = () => + schedule.recurrence.frequency !== "none" + ? { + frequency: schedule.recurrence.frequency, + interval: schedule.recurrence.interval, + ...(schedule.recurrence.endMode === "date" && schedule.recurrence.endDate + ? { endDate: schedule.recurrence.endDate } + : {}), + ...(schedule.recurrence.endMode === "count" ? { maxOccurrences: schedule.recurrence.maxOccurrences } : {}), + } + : undefined + + const handleSubmit = async () => { + if (pin.length !== 4) { + setPinError("Enter your 4-digit PIN") + return + } + + const recipients = Array.from(selectedIds).map((id) => ({ + receiverUserId: id, + amount: parseFloat(amounts[id]), + })) + + setSubmitting(true) + setPinError("") + + try { + let result: any + if (schedule.enabled) { + const scheduledDateTime = new Date(`${schedule.date}T${schedule.time}`) + result = await createScheduledBatchTransfer({ + senderUserId: userId || undefined, + recipients, + pin, + scheduledFor: scheduledDateTime.toISOString(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + recurrence: buildRecurrence(), + notifyRecipientNow, + }) + } else { + result = await createBatchTransfer({ senderUserId: userId || undefined, recipients, pin }) + } + + if (result.success) { + setResults(result.data.results || []) + setStep(3) + refetchBalance() + } + } catch (error: any) { + const errorData = error?.response?.data || {} + if (errorData.requiresPinSetup) { + setShowPinSetup(true) + toast({ + title: "PIN Setup Required", + description: errorData.message || "Please set up your transaction PIN first", + variant: "destructive", + }) + } else { + setPinError(errorData.message || "Something went wrong") + } + setPin("") + } finally { + setSubmitting(false) + } + } + + const handlePinSetupSuccess = () => { + toast({ title: "PIN Setup Complete", description: "You can now send money." }) + setShowPinSetup(false) + } + + const renderRecipients = () => ( +
+
+

Send to multiple people

+

+ Pick recipients and set an amount for each +

+
+ + {walletBalance !== null && ( +
+ Available: {formatCurrency(walletBalance)} + Selected total: {formatCurrency(totalAmount)} +
+ )} + + {walletBalance !== null && totalAmount > walletBalance && ( +
+ + Total exceeds available balance +
+ )} + +
+ {loadingContacts ? ( +
+ Loading contacts... +
+ ) : contacts.length === 0 ? ( +

No contacts yet to send money to.

+ ) : ( + contacts.map((contact) => ( + toggleRecipient(contact.id)} + onAmountChange={(value) => setAmounts((prev) => ({ ...prev, [contact.id]: value }))} + /> + )) + )} +
+ +
+
+ + Schedule for later +
+ + {schedule.enabled && ( + + )} +
+
+ ) + + const renderPin = () => ( +
+ +
+ ) + + const renderResults = () => ( +
+
+

+ {schedule.enabled ? "Scheduled" : "Sent"} +

+

+ {results.filter((r) => r.status === "success").length} of {results.length} succeeded +

+
+
+ +
+
+ ) + + return ( + <> + + + !open && onClose()}> + + +
+
+ +
+ + Send to Multiple + +
+ + Send money to several people in one go + +
+ + {step === 1 && renderRecipients()} + {step === 2 && renderPin()} + {step === 3 && renderResults()} + + + {step === 3 ? ( + + ) : ( + <> + + {step === 1 ? ( + + ) : ( + + )} + + )} + +
+
+ + ) +} diff --git a/components/chat/send-money-modal.tsx b/components/chat/send-money-modal.tsx index cfbb6c4..6f7662a 100644 --- a/components/chat/send-money-modal.tsx +++ b/components/chat/send-money-modal.tsx @@ -4,7 +4,7 @@ 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, Lock } from "lucide-react" +import { DollarSign, X, ArrowRight, Send, Wallet, AlertCircle, Loader2, Lock, CalendarClock } from "lucide-react" import type { Conversation } from "@/types" import { toast } from "@/hooks/use-toast" import Input from "../ui/Input-ant" @@ -13,6 +13,12 @@ import { useCreateEscrowMutation } from "@/states/escrowSlice" import { useChat } from "@/context/ChatContext" import { useAuthToken } from "@/hooks/use-auth-token" import { PinSetupModal } from "@/components/PinSetupModal" +import SchedulePicker, { ScheduleState, defaultScheduleState } from "@/components/transfer/SchedulePicker" +import { scheduleTransfer } from "@/helpers/api" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { formatCurrency } from "@/utils/currency" + +type SendMode = "now" | "hold" | "schedule" interface SendMoneyModalProps { isOpen: boolean @@ -39,15 +45,27 @@ 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 [mode, setMode] = useState("now") const [releaseMode, setReleaseMode] = useState<"manual" | "auto_timeout">("manual") const [autoReleaseDays, setAutoReleaseDays] = useState(3) + const [schedule, setSchedule] = useState(defaultScheduleState) + const [notifyRecipientNow, setNotifyRecipientNow] = useState(false) + const [scheduling, setScheduling] = useState(false) - const submitting = loading || escrowLoading + const submitting = loading || escrowLoading || scheduling // 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 + // Derived from `mode` so handleSubmit's existing branching (which reads these two + // flags) doesn't need to change - the tabs are just a cleaner way to set them. + const holdUntilConfirmed = mode === "hold" + + const handleModeChange = (next: SendMode) => { + setMode(next) + setSchedule((prev) => ({ ...prev, enabled: next === "schedule" })) + } + const walletBalance = walletData?.data?.balance || null const loadingBalance = !walletData && isOpen @@ -58,9 +76,11 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren setNote("") setPin("") setAnimateAmount(false) - setHoldUntilConfirmed(false) + setMode("now") setReleaseMode("manual") setAutoReleaseDays(3) + setSchedule(defaultScheduleState) + setNotifyRecipientNow(false) if (userId) { refetchBalance() } @@ -79,7 +99,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren if (walletBalance !== null && amount > walletBalance) { toast({ title: "Insufficient Balance", - description: `You only have $${walletBalance.toFixed(2)} in your wallet`, + description: `You only have ${formatCurrency(walletBalance)} in your wallet`, variant: "destructive", }) return @@ -125,7 +145,43 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren } try { - if (holdUntilConfirmed && payeeUserId) { + if (schedule.enabled && payeeUserId) { + setScheduling(true) + const scheduledDateTime = new Date(`${schedule.date}T${schedule.time}`) + const recurrence = schedule.recurrence.frequency !== "none" + ? { + frequency: schedule.recurrence.frequency, + interval: schedule.recurrence.interval, + ...(schedule.recurrence.endMode === "date" && schedule.recurrence.endDate + ? { endDate: schedule.recurrence.endDate } + : {}), + ...(schedule.recurrence.endMode === "count" + ? { maxOccurrences: schedule.recurrence.maxOccurrences } + : {}), + } + : undefined + + const result = await scheduleTransfer({ + receiverUserId: payeeUserId, + amount, + description: note, + pin, + scheduledFor: scheduledDateTime.toISOString(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + recurrence, + notifyRecipientNow, + }) + + if (result.success) { + toast({ + title: "Payment Scheduled", + description: result.message || `${formatCurrency(amount)} will be sent to ${currentConversation?.name} on ${scheduledDateTime.toLocaleDateString()}`, + }) + + refetchBalance() + onClose() + } + } else if (holdUntilConfirmed && payeeUserId) { const autoReleaseAt = releaseMode === "auto_timeout" ? new Date(Date.now() + autoReleaseDays * 24 * 60 * 60 * 1000).toISOString() : undefined @@ -143,7 +199,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren if (result.success) { toast({ title: "Funds Held in Escrow", - description: result.message || `$${amount.toFixed(2)} is held until you release it to ${currentConversation?.name}`, + description: result.message || `${formatCurrency(amount)} is held until you release it to ${currentConversation?.name}`, }) if (result.chatMessage && addMessage) { @@ -167,7 +223,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren 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}`, + description: result.message || `${formatCurrency(amount)} ${actionText} to ${currentConversation?.name}`, }) if (result.data?.message && addMessage) { @@ -184,7 +240,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren } } } catch (error: any) { - const errorData = error?.data || {} + const errorData = error?.data || error?.response?.data || {} if (errorData.requiresPinSetup) { setShowPinSetup(true) @@ -214,6 +270,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren } } finally { setPin("") + setScheduling(false) } } @@ -248,7 +305,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren
- Available: ${walletBalance.toFixed(2)} + Available: {formatCurrency(walletBalance)}
) : null} @@ -257,16 +314,16 @@ 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"}`} >
- + RWF
setAmount(Number(e.target.value))} - placeholder="0.00" + placeholder="0" />
@@ -278,7 +335,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren )}
- {[10, 25, 50, 100, 200, 500].map((quickAmount) => ( + {[1000, 2000, 5000, 10000, 20000, 50000].map((quickAmount) => ( ))}
{!currentConversation?.isGroup && payeeUserId && (
- - - {holdUntilConfirmed && ( -
- -
- )} + +
)}
@@ -389,8 +474,8 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren
- Amount - ${amount.toFixed(2)} + Amount + {formatCurrency(amount)}
@@ -431,7 +516,7 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren /> !open && onClose()}> - +
@@ -476,6 +561,11 @@ export default function SendMoneyModal({ isOpen, onClose, recipient = "", curren Processing... + ) : schedule.enabled && payeeUserId ? ( + <> + + Schedule + ) : holdUntilConfirmed && payeeUserId ? ( <> diff --git a/components/chat/shared-wallet-balance-badge.tsx b/components/chat/shared-wallet-balance-badge.tsx new file mode 100644 index 0000000..5080906 --- /dev/null +++ b/components/chat/shared-wallet-balance-badge.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { WalletCards } from "lucide-react"; + +interface SharedWalletBalanceBadgeProps { + balance: number; + currency?: string; +} + +export default function SharedWalletBalanceBadge({ + balance, + currency = "RWF", +}: SharedWalletBalanceBadgeProps) { + const formatCurrency = (amount: number) => { + if (amount >= 1000000) { + return `${(amount / 1000000).toFixed(1)}M`; + } else if (amount >= 1000) { + return `${(amount / 1000).toFixed(1)}K`; + } + return amount.toFixed(0); + }; + + return ( +
+ + + {formatCurrency(balance)} {currency} + +
+ ); +} diff --git a/components/chat/shared-wallet-withdrawal-card.tsx b/components/chat/shared-wallet-withdrawal-card.tsx new file mode 100644 index 0000000..69e65a8 --- /dev/null +++ b/components/chat/shared-wallet-withdrawal-card.tsx @@ -0,0 +1,256 @@ +"use client"; + +import React from "react"; +import { WalletCards, CheckCircle, Ban, Clock, Lock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getCurrentUserId } from "@/utils/tokenUtils"; +import { + useGetSharedWalletWithdrawalByIdQuery, + useApproveSharedWalletWithdrawalMutation, + useDeclineSharedWalletWithdrawalMutation, + useCancelSharedWalletWithdrawalMutation, +} from "@/states/sharedWalletSlice"; +import { toast } from "@/hooks/use-toast"; + +interface SharedWalletWithdrawalMessageData { + type: "shared_wallet_withdrawal_request"; + withdrawalId: string; + sharedWalletId: string; + groupId?: string | null; + amount: number; + currency: string; + requesterId: string; + requesterName: string; + note?: string; + timestamp: string; +} + +interface SharedWalletWithdrawalCardProps { + data: SharedWalletWithdrawalMessageData; + 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 SharedWalletWithdrawalCard: React.FC = ({ data, isMe, chatId }) => { + const currentUserId = getCurrentUserId(); + + // Polls while mounted so a vote cast by another member shows up here without a refresh. + const { data: withdrawalResp } = useGetSharedWalletWithdrawalByIdQuery( + { sharedWalletId: data.sharedWalletId, withdrawalId: data.withdrawalId }, + { pollingInterval: 15000 } + ); + const withdrawal = withdrawalResp?.data; + + const [approveWithdrawal, { isLoading: approving }] = useApproveSharedWalletWithdrawalMutation(); + const [declineWithdrawal, { isLoading: declining }] = useDeclineSharedWalletWithdrawalMutation(); + const [cancelWithdrawal, { isLoading: cancelling }] = useCancelSharedWalletWithdrawalMutation(); + + const [showPinForm, setShowPinForm] = React.useState(false); + const [pin, setPin] = React.useState(""); + + const status: string = withdrawal?.status || "pending"; + const approveCount: number = withdrawal?.approveCount ?? 0; + const declineCount: number = withdrawal?.declineCount ?? 0; + const requiredApprovals: number = withdrawal?.requiredApprovals ?? 1; + const requestedByUserId: string | undefined = withdrawal?.requestedByUserId; + const votes: Array<{ userId: string; decision: "approve" | "decline" }> = withdrawal?.votes || []; + + const isRequester = Boolean(requestedByUserId && currentUserId && requestedByUserId === currentUserId); + const myVote = votes.find((v) => v.userId === currentUserId)?.decision; + const isPending = status === "pending"; + const canVote = isPending && !isRequester && !myVote; + const canCancel = isPending && isRequester; + + const handleApprove = async () => { + if (pin.length !== 4) { + toast({ title: "Enter PIN", description: "Please enter your 4-digit PIN.", variant: "destructive" }); + return; + } + try { + await approveWithdrawal({ sharedWalletId: data.sharedWalletId, withdrawalId: data.withdrawalId, pin, chatId }).unwrap(); + toast({ title: "Vote recorded", description: "Your approval has been recorded." }); + setShowPinForm(false); + setPin(""); + } catch (err: any) { + toast({ title: "Could not approve", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + setPin(""); + } + }; + + const handleDecline = async () => { + try { + await declineWithdrawal({ sharedWalletId: data.sharedWalletId, withdrawalId: data.withdrawalId, chatId }).unwrap(); + toast({ title: "Vote recorded", description: "Your decline has been recorded." }); + } catch (err: any) { + toast({ title: "Could not decline", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + + const handleCancel = async () => { + try { + await cancelWithdrawal({ sharedWalletId: data.sharedWalletId, withdrawalId: data.withdrawalId, chatId }).unwrap(); + toast({ title: "Request cancelled" }); + } catch (err: any) { + toast({ title: "Could not cancel", description: err?.data?.message || "Something went wrong", variant: "destructive" }); + } + }; + + const statusPill = () => { + if (status === "approved") return ( + + Approved + + ); + if (status === "declined") return ( + + Declined + + ); + if (status === "cancelled") return ( + + Cancelled + + ); + return ( + + {approveCount}/{requiredApprovals} approved + + ); + }; + + const shell = ` + w-[260px] rounded-2xl overflow-hidden shadow-sm border + ${status === "approved" + ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800/40' + : status === "declined" || status === "cancelled" + ? '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 === "approved" + ? 'bg-green-500' + : status === "declined" || status === "cancelled" + ? 'bg-gray-400' + : 'bg-amber-500'; + + return ( +
+
+ +
+
+ + + Withdrawal Request + +
+ {statusPill()} +
+ +
+

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

+

+ Requested by {data.requesterName} +

+
+ + {data.note && ( +
+

"{data.note}"

+
+ )} + + {status === "declined" && ( +

+ A majority could no longer approve this request +

+ )} + + {(canVote || canCancel) && ( +
+
+ + {canVote && !showPinForm && ( +
+ + +
+ )} + + {canVote && showPinForm && ( +
+
+ + setPin(e.target.value.replace(/\D/g, ''))} + className="border-0 bg-transparent p-0 h-auto text-center text-base tracking-widest focus-visible:ring-0 focus-visible:ring-offset-0" + autoFocus + onKeyDown={(e) => { if (e.key === 'Enter' && pin.length === 4) handleApprove(); }} + /> +
+
+ + +
+
+ )} + + {canCancel && ( + + )} +
+ )} + +

+ {fmtTime(data.timestamp)} +

+
+ ); +}; diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx index c2c24ae..112eaaa 100644 --- a/components/notifications/NotificationCenter.tsx +++ b/components/notifications/NotificationCenter.tsx @@ -8,14 +8,15 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" -import { Bell, Users, UserPlus, Check, X, Clock, AlertCircle, Info, Loader2, MessageCircle, Image, Video, Music, File, DollarSign, UserMinus, UserCheck, UserX, ShieldCheck, ShieldAlert, Settings, Trash2, HandCoins } from "lucide-react" +import { Bell, Users, UserPlus, Check, X, Clock, AlertCircle, Info, Loader2, MessageCircle, Image, Video, Music, File, DollarSign, UserMinus, UserCheck, UserX, ShieldCheck, ShieldAlert, Settings, Trash2, HandCoins, WalletCards } from "lucide-react" import { toast } from "@/hooks/use-toast" import { useNotifications } from "@/context/NotificationContext" import { useAuthToken } from "@/hooks/use-auth-token" import { useRespondToJoinRequestEnhancedMutation, useGetPendingJoinRequestsQuery, - useGetPendingInvitationsQuery + useGetPendingInvitationsQuery, + useRespondToGroupInvitationMutation } from "@/states/groupSlice" import { useGetPendingInvitationsUnifiedQuery, @@ -39,6 +40,7 @@ const NotificationCenter: React.FC = ({ isOpen, onClose const [respondToJoinRequest, { isLoading: isResponding }] = useRespondToJoinRequestEnhancedMutation() const [respondToContactRequest, { isLoading: isRespondingToContact }] = useRespondToInvitationEnhancedMutation() const [respondToContactByToken, { isLoading: isRespondingByToken }] = useRespondToInvitationByTokenMutation() + const [respondToGroupInvitation, { isLoading: isRespondingToInvitation }] = useRespondToGroupInvitationMutation() const [selectedTab, setSelectedTab] = useState<'all' | 'invitations' | 'requests' | 'contacts' | 'groups' | 'messages'>('all') const [processingRequestId, setProcessingRequestId] = useState(null) const [rejectionReason, setRejectionReason] = useState("") @@ -49,7 +51,7 @@ const NotificationCenter: React.FC = ({ isOpen, onClose token!, { skip: !token } ) - const { data: pendingInvitations } = useGetPendingInvitationsQuery( + const { data: pendingInvitations, refetch: refetchInvitations } = useGetPendingInvitationsQuery( token!, { skip: !token } ) @@ -113,7 +115,20 @@ const NotificationCenter: React.FC = ({ isOpen, onClose case 'PAYMENT_REQUEST_RECEIVED': case 'payment_request_received': return - + + // Shared wallet notifications + case 'SHARED_WALLET_MEMBER_ADDED': + return + case 'SHARED_WALLET_DEPOSIT_RECEIVED': + return + case 'SHARED_WALLET_WITHDRAWAL_REQUESTED': + return + case 'SHARED_WALLET_WITHDRAWAL_APPROVED': + case 'SHARED_WALLET_WITHDRAWAL_EXECUTED': + return + case 'SHARED_WALLET_WITHDRAWAL_DECLINED': + return + // Contact notifications case 'CONTACT_REQUEST_RECEIVED': case 'contact_request': @@ -296,6 +311,45 @@ const NotificationCenter: React.FC = ({ isOpen, onClose } } + // A GROUP_INVITATION notification only carries groupId - look up the matching + // pending GroupMember row (already fetched for the "invitations" tab) to get the + // membershipId respondToGroupInvitation actually needs. + const handleGroupInvitationResponse = async (groupId: string, action: 'accept' | 'reject') => { + const invitation = pendingInvitations?.data?.invitations?.find((inv: any) => inv.groupId === groupId) + if (!invitation) { + toast({ + title: "Error", + description: "This invitation is no longer available", + variant: "destructive", + }) + return + } + + setProcessingRequestId(invitation.id) + try { + await respondToGroupInvitation({ + membershipId: invitation.id, + responseData: { action }, + token: token! + }).unwrap() + + toast({ + title: "Success", + description: `Group invitation ${action === 'accept' ? 'accepted' : 'declined'} successfully`, + }) + + refetchInvitations() + } catch (error: any) { + toast({ + title: "Error", + description: error?.data?.message || `Failed to ${action === 'accept' ? 'accept' : 'decline'} invitation`, + variant: "destructive", + }) + } finally { + setProcessingRequestId(null) + } + } + const handleNotificationClick = (notification: Notification) => { if (!notification.isRead) { markAsRead(notification.id) @@ -312,14 +366,21 @@ const NotificationCenter: React.FC = ({ isOpen, onClose router.push(`/chat?chatId=${notification.data.chatId}`) onClose() } - // Handle navigation for group notifications + // Handle navigation for group notifications - GROUP_INVITATION is excluded here + // since it has its own inline Accept/Decline buttons below and /groups/{groupId} + // has no page (would 404); this only covers other GROUP_* types. else if (notification.data?.groupId && ( notification.type.startsWith('GROUP_') || notification.type.startsWith('group_') - )) { + ) && notification.type !== 'GROUP_INVITATION' && notification.type !== 'group_invitation') { router.push(`/groups/${notification.data.groupId}`) onClose() } + // Handle navigation for shared wallet notifications + else if (notification.data?.sharedWalletId && notification.type.startsWith('SHARED_WALLET_')) { + router.push(`/wallets/shared/${notification.data.sharedWalletId}`) + onClose() + } // Handle navigation for contact notifications else if (notification.data?.contactId && ( notification.type.startsWith('CONTACT_') || @@ -725,6 +786,40 @@ const NotificationCenter: React.FC = ({ isOpen, onClose
) : null )} + + {/* Accept/Decline for group invitations - doesn't rely on + notification.data.actions since respondToGroupInvitation + needs a membershipId, resolved from pendingInvitations below */} + {(notification.type === 'GROUP_INVITATION' || notification.type === 'group_invitation') && + notification.data?.groupId && ( +
+ + +
+ )}
)) diff --git a/components/transfer/ContactListItem.tsx b/components/transfer/ContactListItem.tsx index 11a6b2a..7bff832 100644 --- a/components/transfer/ContactListItem.tsx +++ b/components/transfer/ContactListItem.tsx @@ -1,9 +1,10 @@ "use client"; import React from "react"; -import { Send, Star } from "lucide-react"; +import { Send, Star, Check } from "lucide-react"; import { UserAvatar } from "@/components/UserAvatar"; import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; interface Contact { id: string; @@ -17,17 +18,26 @@ interface Contact { interface ContactListItemProps { contact: Contact; onSelect: (contact: Contact) => void; + /** When true, tapping the row toggles selection (via `onToggle`) instead of calling `onSelect`. */ + selectionMode?: boolean; + isSelected?: boolean; + onToggle?: (contact: Contact) => void; } -const ContactListItem = ({ contact, onSelect }: ContactListItemProps) => { +const ContactListItem = ({ contact, onSelect, selectionMode = false, isSelected = false, onToggle }: ContactListItemProps) => { const nameParts = contact.name.split(" "); const firstName = nameParts[0] || ""; const lastName = nameParts.slice(1).join(" ") || ""; return ( ); }; diff --git a/components/transfer/SchedulePicker.tsx b/components/transfer/SchedulePicker.tsx index 0dc26a8..4fafa5f 100644 --- a/components/transfer/SchedulePicker.tsx +++ b/components/transfer/SchedulePicker.tsx @@ -35,6 +35,10 @@ interface SchedulePickerProps { schedule: ScheduleState; onChange: (schedule: ScheduleState) => void; minDate: string; + /** Skip the built-in "Send now / Schedule for later" toggle - use when the caller + * already has its own control for that choice (e.g. an outer tab/mode picker) and + * guarantees `schedule.enabled` is true whenever this is mounted. */ + hideModeToggle?: boolean; } const frequencies: { value: RecurrenceState["frequency"]; label: string }[] = [ @@ -57,41 +61,43 @@ const endOptions: { value: RecurrenceState["endMode"]; label: string }[] = [ 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 SchedulePicker = ({ schedule, onChange, minDate, hideModeToggle = false }: SchedulePickerProps) => { const update = (patch: Partial) => onChange({ ...schedule, ...patch }); const updateRecurrence = (patch: Partial) => onChange({ ...schedule, recurrence: { ...schedule.recurrence, ...patch } }); return (
-
- - -
+ {!hideModeToggle && ( +
+ + +
+ )} - {schedule.enabled && ( + {(hideModeToggle || schedule.enabled) && (
@@ -123,7 +129,7 @@ const SchedulePicker = ({ schedule, onChange, minDate }: SchedulePickerProps) => -
+
{frequencies.map((f) => ( -

Send Money

- - +

+ {selectionMode ? `Select recipients${selectedIds.size > 0 ? ` (${selectedIds.size})` : ""}` : "Send Money"} +

+ {selectionMode ? ( + + ) : ( + <> + + + + )}
{/* Balance Card */} @@ -382,11 +433,13 @@ const TransferPageLayout = () => { /> {/* Action Buttons Row */} - setIsAddContactOpen(true)} - /> + {!selectionMode && ( + setIsAddContactOpen(true)} + /> + )} {/* Add Contact Modal */} { {/* Contact Selection Card */}
- {/* Tabs */} -
- -
+ {/* Tabs - hidden while selecting multiple, which only works against the full contact list */} + {!selectionMode && ( +
+ +
+ )} {/* Search */}
@@ -469,6 +524,9 @@ const TransferPageLayout = () => { key={contact.id} contact={contact} onSelect={handleContactSelect} + selectionMode={selectionMode} + isSelected={selectedIds.has(contact.id)} + onToggle={handleToggleContact} /> )) ) : ( diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index c787c53..765aa52 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -621,6 +621,36 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { }); }; + const handleScheduledTransferUpdated = (data: { + scheduledTransferId: string; + status: "scheduled" | "completed" | "cancelled" | "failed"; + transactionId?: string | null; + chatId?: string | null; + }) => { + // Update every message across all chats that references this scheduledTransferId + setMessages(prev => { + const updated = { ...prev }; + for (const chatId of Object.keys(updated)) { + updated[chatId] = updated[chatId].map(msg => { + if (msg.messageType !== "money") return msg; + try { + const parsed = JSON.parse(msg.content); + if (parsed?.type === "scheduled_transfer" && parsed.scheduledTransferId === data.scheduledTransferId) { + return { + ...msg, + content: JSON.stringify({ ...parsed, status: data.status, transactionId: data.transactionId ?? parsed.transactionId }), + }; + } + } catch { + // not JSON, skip + } + return msg; + }); + } + return updated; + }); + }; + const handleReactionUpdated = (data: { chatId: string; messageId: string; @@ -652,6 +682,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { socketService.onError(handleError); socketService.onMoneyReceived(handleMoneyReceived); socketService.onPaymentRequestUpdated(handlePaymentRequestUpdated); + socketService.onScheduledTransferUpdated(handleScheduledTransferUpdated); socketService.onReactionUpdated(handleReactionUpdated); return () => { @@ -670,6 +701,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { socketService.offError(handleError); socketService.offMoneyReceived(handleMoneyReceived); socketService.offPaymentRequestUpdated(handlePaymentRequestUpdated); + socketService.offScheduledTransferUpdated(handleScheduledTransferUpdated); socketService.offReactionUpdated(handleReactionUpdated); }; }, [isConnected, activeChat, userId, refetchMessages, sortConversations, conversations, token, refetchChats, updateSecureConversationPreview]); diff --git a/helpers/api.ts b/helpers/api.ts index 08abfb9..5aef20c 100644 --- a/helpers/api.ts +++ b/helpers/api.ts @@ -187,6 +187,7 @@ export interface ScheduleTransferParams { scheduledFor: string; timezone?: string; recurrence?: ScheduleRecurrenceInput; + notifyRecipientNow?: boolean; } export const scheduleTransfer = async (params: ScheduleTransferParams) => { @@ -300,6 +301,7 @@ export const createScheduledBatchTransfer = async (params: { scheduledFor: string; timezone: string; recurrence?: ScheduleRecurrenceInput | null; + notifyRecipientNow?: boolean; }) => { const res = await apiPost('/scheduled-transfers/batch', params); return res.data; diff --git a/services/socketService.ts b/services/socketService.ts index c430aa7..cab0059 100644 --- a/services/socketService.ts +++ b/services/socketService.ts @@ -537,6 +537,28 @@ class SocketService { } } + // Listen for scheduled-transfer status updates (completed, failed, or cancelled) + onScheduledTransferUpdated(callback: (data: { + scheduledTransferId: string; + status: "scheduled" | "completed" | "cancelled" | "failed"; + transactionId?: string | null; + chatId?: string | null; + }) => void) { + if (this.socket) { + this.socket.on("scheduled_transfer_updated", callback) + } + } + + offScheduledTransferUpdated(callback?: (data: any) => void) { + if (this.socket) { + if (callback) { + this.socket.off("scheduled_transfer_updated", callback) + } else { + this.socket.off("scheduled_transfer_updated") + } + } + } + // Reaction methods addReaction(chatId: string, messageId: string, emoji: string) { if (this.socket) { @@ -608,6 +630,55 @@ class SocketService { } } + // Listen for shared wallet balance updates (deposit or completed withdrawal). + // groupId is null for a standalone shared wallet (no attached group). + onSharedWalletBalanceUpdate(callback: (data: { + sharedWalletId: string; + groupId: string | null; + balance: number; + }) => void) { + if (this.socket) { + this.socket.on("shared_wallet_balance_update", callback) + } + } + + offSharedWalletBalanceUpdate(callback?: (data: any) => void) { + if (this.socket) { + if (callback) { + this.socket.off("shared_wallet_balance_update", callback) + } else { + this.socket.off("shared_wallet_balance_update") + } + } + } + + // Listen for shared wallet withdrawal request updates (vote cast, executed, etc.) - + // fans out to every active member's room, not just chat participants, so this fires + // for standalone shared wallets too (which have no chat). + onSharedWalletWithdrawalUpdated(callback: (data: { + sharedWalletId: string; + groupId: string | null; + withdrawalId: string; + status: string; + approveCount: number; + declineCount: number; + transactionId?: string | null; + }) => void) { + if (this.socket) { + this.socket.on("shared_wallet_withdrawal_updated", callback) + } + } + + offSharedWalletWithdrawalUpdated(callback?: (data: any) => void) { + if (this.socket) { + if (callback) { + this.socket.off("shared_wallet_withdrawal_updated", callback) + } else { + this.socket.off("shared_wallet_withdrawal_updated") + } + } + } + // Group contribution real-time events onGroupContributionUpdated(callback: (data: { groupId: string; diff --git a/states/apiSlice.ts b/states/apiSlice.ts index 08eafa9..fd7e988 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', 'Escrow'], + tagTypes: ['Group', 'GroupMember', 'GroupInvitation', 'GroupJoinRequest', 'Notification', 'Contact', 'ContactInvitation', 'User', 'Chat', 'ChatMessage', 'ChatStatus', 'Wallet', 'Escrow', 'SharedWalletWithdrawal', 'SharedWallets'], endpoints: () => ({}) }) diff --git a/states/escrowSlice.ts b/states/escrowSlice.ts index 7d316a5..15d08e3 100644 --- a/states/escrowSlice.ts +++ b/states/escrowSlice.ts @@ -54,9 +54,34 @@ export const escrowSlice = apiSlice.injectEndpoints({ ] }), - refundEscrow: builder.mutation({ + proposeSettlement: builder.mutation({ + query: ({ escrowId, payeeAmount, note }) => ({ + url: `/escrows/${escrowId}/propose`, + method: "POST", + body: { payeeAmount, note } + }), + invalidatesTags: (result, error, { escrowId, chatId }) => [ + "Escrow", + { type: "Escrow", id: escrowId }, + { type: "ChatMessage", id: chatId } + ] + }), + + acceptSettlement: builder.mutation({ + query: ({ escrowId }) => ({ + url: `/escrows/${escrowId}/accept`, + method: "POST" + }), + invalidatesTags: (result, error, { escrowId, chatId }) => [ + "Escrow", + { type: "Escrow", id: escrowId }, + { type: "ChatMessage", id: chatId } + ] + }), + + declineSettlement: builder.mutation({ query: ({ escrowId }) => ({ - url: `/escrows/${escrowId}/refund`, + url: `/escrows/${escrowId}/decline`, method: "POST" }), invalidatesTags: (result, error, { escrowId, chatId }) => [ @@ -87,6 +112,8 @@ export const { useGetEscrowByIdQuery, useReleaseEscrowMutation, useFulfillEscrowMutation, - useRefundEscrowMutation, + useProposeSettlementMutation, + useAcceptSettlementMutation, + useDeclineSettlementMutation, useDisputeEscrowMutation, } = escrowSlice; diff --git a/states/groupSlice.ts b/states/groupSlice.ts index 2bb2a0a..a3f7edf 100644 --- a/states/groupSlice.ts +++ b/states/groupSlice.ts @@ -21,7 +21,7 @@ export const groupSlice = apiSlice.injectEndpoints({ }, } }, - invalidatesTags: ["Group"], + invalidatesTags: ["Group", "SharedWallets"], }), getGroups: builder.query<{ data: { groups: Group[] } }, string>({ diff --git a/states/sharedWalletSlice.ts b/states/sharedWalletSlice.ts new file mode 100644 index 0000000..fb48553 --- /dev/null +++ b/states/sharedWalletSlice.ts @@ -0,0 +1,167 @@ +import { apiSlice } from "@/states/apiSlice"; + +export const sharedWalletSlice = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + createSharedWallet: builder.mutation({ + query: ({ name, withdrawalPolicy, memberIds, pin }) => ({ + url: `/shared-wallets`, + method: "POST", + body: { name, withdrawalPolicy, memberIds, pin }, + }), + invalidatesTags: ["SharedWallets"], + }), + + getMySharedWallets: builder.query({ + query: () => `/shared-wallets`, + providesTags: ["SharedWallets"], + }), + + getSharedWalletById: builder.query({ + query: (sharedWalletId: string) => `/shared-wallets/${sharedWalletId}`, + providesTags: (result, error, sharedWalletId) => [{ type: "SharedWallets", id: sharedWalletId }], + }), + + getSharedWalletActivity: builder.query({ + query: (sharedWalletId: string) => `/shared-wallets/${sharedWalletId}/activity`, + providesTags: (result, error, sharedWalletId) => [{ type: "Wallet", id: sharedWalletId }], + }), + + getSharedWalletMembers: builder.query({ + query: (sharedWalletId: string) => `/shared-wallets/${sharedWalletId}/members`, + providesTags: (result, error, sharedWalletId) => [{ type: "GroupMember", id: sharedWalletId }], + }), + + addSharedWalletMember: builder.mutation({ + query: ({ sharedWalletId, userId }) => ({ + url: `/shared-wallets/${sharedWalletId}/members`, + method: "POST", + body: { userId }, + }), + invalidatesTags: (result, error, { sharedWalletId }) => [ + { type: "GroupMember", id: sharedWalletId }, + { type: "SharedWallets", id: sharedWalletId }, + ], + }), + + removeSharedWalletMember: builder.mutation({ + query: ({ sharedWalletId, userId }) => ({ + url: `/shared-wallets/${sharedWalletId}/members/${userId}`, + method: "DELETE", + }), + invalidatesTags: (result, error, { sharedWalletId }) => [ + { type: "GroupMember", id: sharedWalletId }, + { type: "SharedWallets", id: sharedWalletId }, + ], + }), + + leaveSharedWallet: builder.mutation({ + query: (sharedWalletId: string) => ({ + url: `/shared-wallets/${sharedWalletId}/leave`, + method: "POST", + }), + invalidatesTags: ["SharedWallets"], + }), + + depositIntoSharedWallet: builder.mutation({ + query: ({ sharedWalletId, amount, pin, note }) => ({ + url: `/shared-wallets/${sharedWalletId}/deposit`, + method: "POST", + body: { amount, pin, note }, + }), + invalidatesTags: (result, error, { sharedWalletId }) => [ + "SharedWallets", + { type: "SharedWallets", id: sharedWalletId }, + { type: "Wallet", id: sharedWalletId }, + ], + }), + + withdrawFromSharedWallet: builder.mutation({ + query: ({ sharedWalletId, amount, pin, note }) => ({ + url: `/shared-wallets/${sharedWalletId}/withdraw`, + method: "POST", + body: { amount, pin, note }, + }), + invalidatesTags: (result, error, { sharedWalletId, chatId }) => [ + "SharedWallets", + { type: "SharedWallets", id: sharedWalletId }, + { type: "Wallet", id: sharedWalletId }, + { type: "ChatMessage", id: chatId }, + ], + }), + + proposeSharedWalletWithdrawal: builder.mutation({ + query: ({ sharedWalletId, amount, pin, note }) => ({ + url: `/shared-wallets/${sharedWalletId}/withdrawals`, + method: "POST", + body: { amount, pin, note }, + }), + invalidatesTags: (result, error, { sharedWalletId, chatId }) => [ + "SharedWalletWithdrawal", + { type: "Wallet", id: sharedWalletId }, + { type: "ChatMessage", id: chatId }, + ], + }), + + getSharedWalletWithdrawalById: builder.query({ + query: ({ sharedWalletId, withdrawalId }) => + `/shared-wallets/${sharedWalletId}/withdrawals/${withdrawalId}`, + providesTags: (result, error, { withdrawalId }) => [ + { type: "SharedWalletWithdrawal", id: withdrawalId }, + ], + }), + + approveSharedWalletWithdrawal: builder.mutation({ + query: ({ sharedWalletId, withdrawalId, pin }) => ({ + url: `/shared-wallets/${sharedWalletId}/withdrawals/${withdrawalId}/approve`, + method: "POST", + body: { pin }, + }), + invalidatesTags: (result, error, { sharedWalletId, withdrawalId, chatId }) => [ + "SharedWallets", + { type: "Wallet", id: sharedWalletId }, + { type: "SharedWalletWithdrawal", id: withdrawalId }, + { type: "ChatMessage", id: chatId }, + ], + }), + + declineSharedWalletWithdrawal: builder.mutation({ + query: ({ sharedWalletId, withdrawalId }) => ({ + url: `/shared-wallets/${sharedWalletId}/withdrawals/${withdrawalId}/decline`, + method: "POST", + }), + invalidatesTags: (result, error, { withdrawalId, chatId }) => [ + { type: "SharedWalletWithdrawal", id: withdrawalId }, + { type: "ChatMessage", id: chatId }, + ], + }), + + cancelSharedWalletWithdrawal: builder.mutation({ + query: ({ sharedWalletId, withdrawalId }) => ({ + url: `/shared-wallets/${sharedWalletId}/withdrawals/${withdrawalId}/cancel`, + method: "POST", + }), + invalidatesTags: (result, error, { withdrawalId, chatId }) => [ + { type: "SharedWalletWithdrawal", id: withdrawalId }, + { type: "ChatMessage", id: chatId }, + ], + }), + }), +}); + +export const { + useCreateSharedWalletMutation, + useGetMySharedWalletsQuery, + useGetSharedWalletByIdQuery, + useGetSharedWalletActivityQuery, + useGetSharedWalletMembersQuery, + useAddSharedWalletMemberMutation, + useRemoveSharedWalletMemberMutation, + useLeaveSharedWalletMutation, + useDepositIntoSharedWalletMutation, + useWithdrawFromSharedWalletMutation, + useProposeSharedWalletWithdrawalMutation, + useGetSharedWalletWithdrawalByIdQuery, + useApproveSharedWalletWithdrawalMutation, + useDeclineSharedWalletWithdrawalMutation, + useCancelSharedWalletWithdrawalMutation, +} = sharedWalletSlice; diff --git a/types/dashboard.ts b/types/dashboard.ts index 3bb6ccb..b24f486 100644 --- a/types/dashboard.ts +++ b/types/dashboard.ts @@ -243,6 +243,7 @@ export interface WalletSummary { wallet: { id: string; balance: number; + heldBalance: number; currency: string; isActive: boolean; entityType: 'user' | 'organization'; @@ -251,6 +252,7 @@ export interface WalletSummary { balanceBreakdown: { total: number; restricted: number; + held: number; available: number; }; restrictions: WalletRestriction[]; diff --git a/types/group.types.ts b/types/group.types.ts index 059ffdf..42bc792 100644 --- a/types/group.types.ts +++ b/types/group.types.ts @@ -9,6 +9,9 @@ export interface CreateGroupRequest { adminId?: string hasFundraising?: boolean fundraisingTarget?: number + hasSharedWallet?: boolean + withdrawalPolicy?: 'free' | 'approval' + pin?: string expirationDate?: string expirationType?: 'custom_date' | 'target_reached' | 'deadline_reached' | 'never' hasAdditionalInfo?: boolean @@ -34,6 +37,8 @@ export interface Group { fundraisingTarget?: number fundraisingCurrentAmount: number fundraisingProgress?: number + sharedWalletId?: string + withdrawalPolicy?: 'free' | 'approval' walletId?: string walletBalance?: number expirationDate?: string diff --git a/types/notification.types.ts b/types/notification.types.ts index 91d9d37..1549629 100644 --- a/types/notification.types.ts +++ b/types/notification.types.ts @@ -68,6 +68,14 @@ export enum NotificationType { WALLET_RESTRICTION_REMOVED = 'WALLET_RESTRICTION_REMOVED', LOW_BALANCE_WARNING = 'LOW_BALANCE_WARNING', + // Shared wallet notifications (group-attached or standalone) + SHARED_WALLET_MEMBER_ADDED = 'SHARED_WALLET_MEMBER_ADDED', + SHARED_WALLET_DEPOSIT_RECEIVED = 'SHARED_WALLET_DEPOSIT_RECEIVED', + SHARED_WALLET_WITHDRAWAL_REQUESTED = 'SHARED_WALLET_WITHDRAWAL_REQUESTED', + SHARED_WALLET_WITHDRAWAL_APPROVED = 'SHARED_WALLET_WITHDRAWAL_APPROVED', + SHARED_WALLET_WITHDRAWAL_DECLINED = 'SHARED_WALLET_WITHDRAWAL_DECLINED', + SHARED_WALLET_WITHDRAWAL_EXECUTED = 'SHARED_WALLET_WITHDRAWAL_EXECUTED', + // Action notifications (Tickets, Services, etc.) ACTION_CREATED = 'ACTION_CREATED', ACTION_UPDATED = 'ACTION_UPDATED', @@ -208,6 +216,11 @@ export interface Notification { restrictionType?: string; thresholdAmount?: number; + // Shared wallet data + sharedWalletId?: string; + sharedWalletName?: string; + withdrawalId?: string; + // External account data externalAccountId?: string; externalAccountType?: string; diff --git a/utils/currency.ts b/utils/currency.ts new file mode 100644 index 0000000..b6cca5b --- /dev/null +++ b/utils/currency.ts @@ -0,0 +1,8 @@ +export function formatCurrency(amount: number, currency: string = "RWF"): string { + return new Intl.NumberFormat("en-RW", { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} From d6019e35460cfe881da64b2e7d195e7760e3b0b8 Mon Sep 17 00:00:00 2001 From: Olivier BYIRINGIRO Date: Mon, 21 Sep 2026 15:24:36 +0200 Subject: [PATCH 2/2] feat: enhance shared wallet functionality and UI improvements - Added section ID to SharedWallets component for better accessibility. - Improved WithdrawFromSharedWalletModal with max height and overflow handling. - Updated ChatHeader to navigate to shared wallet details on badge click. - Integrated PIN setup modal in SharedWalletWithdrawalCard for enhanced security. - Expanded NotificationCenter to handle shared wallet invitations with accept/reject options. - Introduced new API endpoints for managing shared wallet invitations and policy changes. - Enhanced socket service to listen for shared wallet policy change updates. - Updated PinEntry component for improved UI and accessibility. - Added new types and state management for shared wallet invitations and policy changes. --- .../Wallet/AddSharedWalletMemberModal.tsx | 10 +- .../Wallet/DepositIntoSharedWalletModal.tsx | 2 +- components/Wallet/QuickActions.tsx | 10 +- components/Wallet/SharedWalletDetail.tsx | 355 ++++++++++++++++-- components/Wallet/SharedWallets.tsx | 2 +- .../Wallet/WithdrawFromSharedWalletModal.tsx | 2 +- components/chat/chat-header.tsx | 14 +- .../chat/shared-wallet-withdrawal-card.tsx | 19 +- .../notifications/NotificationCenter.tsx | 110 +++++- components/transfer/PinEntry.tsx | 34 +- services/socketService.ts | 24 ++ states/apiSlice.ts | 2 +- states/sharedWalletSlice.ts | 103 +++++ types/notification.types.ts | 1 + 14 files changed, 625 insertions(+), 63 deletions(-) diff --git a/components/Wallet/AddSharedWalletMemberModal.tsx b/components/Wallet/AddSharedWalletMemberModal.tsx index e719a35..9fe40d4 100644 --- a/components/Wallet/AddSharedWalletMemberModal.tsx +++ b/components/Wallet/AddSharedWalletMemberModal.tsx @@ -61,11 +61,11 @@ export default function AddSharedWalletMemberModal({ for (const userId of selected) { await addMember({ sharedWalletId, userId }).unwrap() } - toast({ title: selected.length === 1 ? "Member added" : `${selected.length} members added` }) + toast({ title: selected.length === 1 ? "Invitation sent" : `${selected.length} invitations sent` }) onClose() } catch (error: any) { toast({ - title: "Could not add member", + title: "Could not send invitation", description: error?.data?.message || "Something went wrong", variant: "destructive", }) @@ -76,9 +76,9 @@ export default function AddSharedWalletMemberModal({ !open && onClose()}> - Add members + Invite members - Pick from your contacts who aren't already in this wallet. + Pick from your contacts who aren't already in this wallet. They'll need to accept before joining. @@ -135,7 +135,7 @@ export default function AddSharedWalletMemberModal({ className="bg-brand-green dark:bg-brand-gold text-white dark:text-darkBg-main hover:bg-brand-green/90 dark:hover:bg-brand-gold/90" > {isAdding && } - Add {selected.length > 0 ? `(${selected.length})` : ""} + Invite {selected.length > 0 ? `(${selected.length})` : ""} diff --git a/components/Wallet/DepositIntoSharedWalletModal.tsx b/components/Wallet/DepositIntoSharedWalletModal.tsx index 4ffc8a4..b207923 100644 --- a/components/Wallet/DepositIntoSharedWalletModal.tsx +++ b/components/Wallet/DepositIntoSharedWalletModal.tsx @@ -85,7 +85,7 @@ export default function DepositIntoSharedWalletModal({ !open && onClose()}> - + Deposit into {walletName} diff --git a/components/Wallet/QuickActions.tsx b/components/Wallet/QuickActions.tsx index df6f5ab..82290a5 100644 --- a/components/Wallet/QuickActions.tsx +++ b/components/Wallet/QuickActions.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useRouter } from 'next/navigation'; -import { Send, HandCoins, QrCode, ScanLine } from 'lucide-react'; +import { Send, HandCoins, QrCode, ScanLine, WalletCards } from 'lucide-react'; import { useAccent } from '@/hooks/use-accent'; interface QuickActionsProps { @@ -22,10 +22,16 @@ export const QuickActions: React.FC = ({ entityId }) => { { key: 'request', label: 'Request', icon: , go: () => router.push('/home/request') }, { key: 'receive', label: 'Receive', icon: , go: () => router.push(`/welcome/${entityId}`) }, { key: 'scan', label: 'Scan', icon: , go: () => router.push('/home/scan') }, + { + key: 'shared', + label: 'Shared', + icon: , + go: () => document.getElementById('shared-wallets-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }, ]; return ( -
+
{actions.map((a) => ( + +
+ )} + {!isRequester && showPin && ( +
+ setPin(e.target.value.replace(/\D/g, ""))} + className="h-8 text-center" + /> + +
+ )} + {isRequester && ( + + )} + + + ) +} + +function PendingPolicyChangeRow({ sharedWalletId, policyChange, onChanged }: { sharedWalletId: string; policyChange: any; onChanged: () => void }) { + const currentUserId = getCurrentUserId() + const isProposer = policyChange.proposedByUserId === currentUserId + + const [approve, { isLoading: approving }] = useApproveSharedWalletPolicyChangeMutation() + const [decline, { isLoading: declining }] = useDeclineSharedWalletPolicyChangeMutation() + const [cancel, { isLoading: cancelling }] = useCancelSharedWalletPolicyChangeMutation() + + const handleApprove = async () => { + try { + await approve({ sharedWalletId, policyChangeId: policyChange.id }).unwrap() + toast({ title: "Vote recorded" }) + onChanged() + } catch (err: any) { + toast({ title: "Could not approve", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleDecline = async () => { + try { + await decline({ sharedWalletId, policyChangeId: policyChange.id }).unwrap() + toast({ title: "Vote recorded" }) + onChanged() + } catch (err: any) { + toast({ title: "Could not decline", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleCancel = async () => { + try { + await cancel({ sharedWalletId, policyChangeId: policyChange.id }).unwrap() + toast({ title: "Proposal cancelled" }) + onChanged() + } catch (err: any) { + toast({ title: "Could not cancel", description: err?.data?.message, variant: "destructive" }) + } + } + return (
- {entry.approveCount}/{entry.requiredApprovals} approved + {policyChange.approveCount}/{policyChange.requiredApprovals} approved
- {entry.amount.toLocaleString()} {entry.currency} + Switch to free withdrawals

- Requested by {isRequester ? "you" : (entry.requestedByName || "a member")} + Proposed by {isProposer ? "you" : "a member"}

- {entry.note &&

"{entry.note}"

} - {!isRequester && !showPin && ( + {!isProposer && (
-
)} - {!isRequester && showPin && ( -
- setPin(e.target.value.replace(/\D/g, ""))} - className="h-8 text-center" - /> - -
- )} - {isRequester && ( + {isProposer && ( )}
@@ -141,18 +236,33 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: const { data: membersResp, refetch: refetchMembers } = useGetSharedWalletMembersQuery(sharedWalletId) const [removeMember] = useRemoveSharedWalletMemberMutation() const [leaveWallet, { isLoading: leaving }] = useLeaveSharedWalletMutation() + const [transferOwnership, { isLoading: transferring }] = useTransferSharedWalletOwnershipMutation() + const [deleteWallet, { isLoading: deleting }] = useDeleteSharedWalletMutation() + const [tightenPolicy, { isLoading: tightening }] = useTightenSharedWalletPolicyMutation() + const [proposePolicyChange, { isLoading: proposingPolicyChange }] = useProposeSharedWalletPolicyChangeMutation() + const { data: policyChangeResp, refetch: refetchPolicyChange } = useGetPendingSharedWalletPolicyChangeQuery(sharedWalletId) const [showWithdraw, setShowWithdraw] = useState(false) const [showDeposit, setShowDeposit] = useState(false) const [showAddMember, setShowAddMember] = useState(false) const [removeTarget, setRemoveTarget] = useState<{ userId: string; userName: string } | null>(null) const [removingMember, setRemovingMember] = useState(false) + const [transferTarget, setTransferTarget] = useState<{ userId: string; userName: string } | null>(null) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const wallet = walletResp?.data const activity: any[] = activityResp?.data ?? [] const members: any[] = membersResp?.data ?? [] const myMembership = members.find((m) => m.userId === currentUserId) const canManageMembers = myMembership?.role === "owner" || myMembership?.role === "admin" + const isOwner = myMembership?.role === "owner" + const pendingPolicyChange = policyChangeResp?.data ?? null + + const { data: pendingMembersResp } = useGetSharedWalletPendingMembersQuery(sharedWalletId, { + skip: !canManageMembers || !!wallet?.groupId, + }) + const pendingMembers: any[] = pendingMembersResp?.data ?? [] + const [cancelInvite, { isLoading: cancellingInvite }] = useRemoveSharedWalletMemberMutation() useEffect(() => { const onBalance = (data: { sharedWalletId: string }) => { @@ -164,11 +274,19 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: const onWithdrawal = (data: { sharedWalletId: string }) => { if (data.sharedWalletId === sharedWalletId) refetchActivity() } + const onPolicyChange = (data: { sharedWalletId: string }) => { + if (data.sharedWalletId === sharedWalletId) { + refetch() + refetchPolicyChange() + } + } socketService.onSharedWalletBalanceUpdate(onBalance) socketService.onSharedWalletWithdrawalUpdated(onWithdrawal) + socketService.onSharedWalletPolicyChangeUpdated(onPolicyChange) return () => { socketService.offSharedWalletBalanceUpdate(onBalance) socketService.offSharedWalletWithdrawalUpdated(onWithdrawal) + socketService.offSharedWalletPolicyChangeUpdated(onPolicyChange) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sharedWalletId]) @@ -205,6 +323,58 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: } } + const handleCancelInvite = async (userId: string) => { + try { + await cancelInvite({ sharedWalletId, userId }).unwrap() + toast({ title: "Invitation cancelled" }) + } catch (err: any) { + toast({ title: "Could not cancel invitation", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleConfirmTransfer = async () => { + if (!transferTarget) return + try { + await transferOwnership({ sharedWalletId, newOwnerUserId: transferTarget.userId }).unwrap() + toast({ title: `${transferTarget.userName} is now the owner` }) + setTransferTarget(null) + refetchMembers() + } catch (err: any) { + toast({ title: "Could not transfer ownership", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleTightenPolicy = async () => { + try { + await tightenPolicy(sharedWalletId).unwrap() + toast({ title: "Withdrawal policy updated", description: "Withdrawals now require approval" }) + refetch() + } catch (err: any) { + toast({ title: "Could not update policy", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleProposePolicyChange = async () => { + try { + await proposePolicyChange(sharedWalletId).unwrap() + toast({ title: "Policy change proposed" }) + refetchPolicyChange() + } catch (err: any) { + toast({ title: "Could not propose policy change", description: err?.data?.message, variant: "destructive" }) + } + } + + const handleConfirmDelete = async () => { + try { + await deleteWallet(sharedWalletId).unwrap() + toast({ title: "Shared wallet deleted" }) + router.push("/wallet/" + currentUserId) + } catch (err: any) { + toast({ title: "Could not delete wallet", description: err?.data?.message, variant: "destructive" }) + setShowDeleteConfirm(false) + } + } + return ( <>
@@ -240,6 +410,16 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: Open {wallet.groupName || "group"} chat )} + {!wallet.groupId && wallet.withdrawalPolicy === "free" && canManageMembers && ( + + )} + {!wallet.groupId && wallet.withdrawalPolicy === "approval" && !pendingPolicyChange && myMembership && ( + + )} @@ -249,6 +429,13 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: + {pendingPolicyChange && ( + { refetchPolicyChange(); refetch(); }} + /> + )} {activity.length === 0 ? (

No activity yet.

) : ( @@ -292,6 +479,32 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: Add members )} + {!wallet.groupId && canManageMembers && pendingMembers.length > 0 && ( +
+

+ Invited, awaiting response +

+
+ {pendingMembers.map((m) => ( + +
+ +

{m.userName}

+
+ +
+ ))} +
+
+ )} {members.map((m) => (
@@ -301,15 +514,30 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId:

{m.role}

- {canManageMembers && !wallet.groupId && m.role !== "owner" && m.userId !== currentUserId && ( - + {!wallet.groupId && m.role !== "owner" && m.userId !== currentUserId && ( +
+ {isOwner && ( + + )} + {canManageMembers && ( + + )} +
)} ))} @@ -318,6 +546,17 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: Leave shared wallet )} + {!wallet.groupId && isOwner && ( + + )} @@ -349,6 +588,60 @@ export default function SharedWalletDetail({ sharedWalletId }: { sharedWalletId: + !open && setTransferTarget(null)}> + + +
+
+ +
+ Transfer ownership +
+ + Make {transferTarget?.userName} the owner of "{wallet.name}"? + You'll become an admin and lose owner-only controls. + +
+ + Cancel + { e.preventDefault(); handleConfirmTransfer(); }} + disabled={transferring} + > + {transferring ? : null} + Transfer + + +
+
+ + + + +
+
+ +
+ Delete wallet +
+ + Permanently delete "{wallet.name}"? All members will lose access. This cannot be undone. + +
+ + Cancel + { e.preventDefault(); handleConfirmDelete(); }} + disabled={deleting} + className="bg-red-600 hover:bg-red-700 text-white" + > + {deleting ? : null} + Delete + + +
+
+ setShowAddMember(false)} diff --git a/components/Wallet/SharedWallets.tsx b/components/Wallet/SharedWallets.tsx index 4f3584b..452d575 100644 --- a/components/Wallet/SharedWallets.tsx +++ b/components/Wallet/SharedWallets.tsx @@ -32,7 +32,7 @@ export const SharedWallets: React.FC = () => { const cardBase = `bg-white ${accent.darkBgCard} border border-gray-100 dark:border-white/[0.06]`; return ( -
+
diff --git a/components/Wallet/WithdrawFromSharedWalletModal.tsx b/components/Wallet/WithdrawFromSharedWalletModal.tsx index 254979f..0943aaa 100644 --- a/components/Wallet/WithdrawFromSharedWalletModal.tsx +++ b/components/Wallet/WithdrawFromSharedWalletModal.tsx @@ -99,7 +99,7 @@ export default function WithdrawFromSharedWalletModal({ !open && onClose()}> - + Withdraw from {walletName} diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index d455a8f..47f7fb4 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -66,6 +67,7 @@ export default function ChatHeader({ isGroupAdmin = false, }: ChatHeaderProps) { const chat = useChat(); + const router = useRouter(); const { getToken } = useAuthToken(); const token = getToken(); @@ -218,7 +220,17 @@ export default function ChatHeader({ !group?.hasFundraising && group?.sharedWalletId && (
- +
)} diff --git a/components/chat/shared-wallet-withdrawal-card.tsx b/components/chat/shared-wallet-withdrawal-card.tsx index 69e65a8..6bf582d 100644 --- a/components/chat/shared-wallet-withdrawal-card.tsx +++ b/components/chat/shared-wallet-withdrawal-card.tsx @@ -12,6 +12,7 @@ import { useCancelSharedWalletWithdrawalMutation, } from "@/states/sharedWalletSlice"; import { toast } from "@/hooks/use-toast"; +import { PinSetupModal } from "@/components/PinSetupModal"; interface SharedWalletWithdrawalMessageData { type: "shared_wallet_withdrawal_request"; @@ -64,6 +65,7 @@ export const SharedWalletWithdrawalCard: React.FC { + toast({ title: "PIN Setup Complete", description: "You can now approve this request." }); + setShowPinSetup(false); + setShowPinForm(true); + }; + const handleDecline = async () => { try { await declineWithdrawal({ sharedWalletId: data.sharedWalletId, withdrawalId: data.withdrawalId, chatId }).unwrap(); @@ -154,6 +168,8 @@ export const SharedWalletWithdrawalCard: React.FC +
@@ -252,5 +268,6 @@ export const SharedWalletWithdrawalCard: React.FC
+ ); }; diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx index 112eaaa..cb82468 100644 --- a/components/notifications/NotificationCenter.tsx +++ b/components/notifications/NotificationCenter.tsx @@ -23,6 +23,10 @@ import { useRespondToInvitationEnhancedMutation, useRespondToInvitationByTokenMutation } from "@/states/contactSlice" +import { + useGetPendingSharedWalletInvitationsQuery, + useRespondToSharedWalletInvitationMutation +} from "@/states/sharedWalletSlice" import type { Notification } from "@/types/notification.types" import { formatDistanceToNow } from "date-fns" @@ -41,6 +45,7 @@ const NotificationCenter: React.FC = ({ isOpen, onClose const [respondToContactRequest, { isLoading: isRespondingToContact }] = useRespondToInvitationEnhancedMutation() const [respondToContactByToken, { isLoading: isRespondingByToken }] = useRespondToInvitationByTokenMutation() const [respondToGroupInvitation, { isLoading: isRespondingToInvitation }] = useRespondToGroupInvitationMutation() + const [respondToSharedWalletInvitation, { isLoading: isRespondingToSharedWalletInvitation }] = useRespondToSharedWalletInvitationMutation() const [selectedTab, setSelectedTab] = useState<'all' | 'invitations' | 'requests' | 'contacts' | 'groups' | 'messages'>('all') const [processingRequestId, setProcessingRequestId] = useState(null) const [rejectionReason, setRejectionReason] = useState("") @@ -60,6 +65,7 @@ const NotificationCenter: React.FC = ({ isOpen, onClose page: 1, limit: 20 }, { skip: !token }) + const { data: pendingSharedWalletInvitations, refetch: refetchSharedWalletInvitations } = useGetPendingSharedWalletInvitationsQuery(undefined) const getNotificationIcon = (type: string) => { switch (type) { @@ -128,6 +134,23 @@ const NotificationCenter: React.FC = ({ isOpen, onClose return case 'SHARED_WALLET_WITHDRAWAL_DECLINED': return + case 'SHARED_WALLET_INVITATION': + return + case 'SHARED_WALLET_INVITATION_ACCEPTED': + return + case 'SHARED_WALLET_INVITATION_REJECTED': + return + case 'SHARED_WALLET_OWNERSHIP_TRANSFERRED': + return + case 'SHARED_WALLET_DELETED': + return + case 'SHARED_WALLET_POLICY_CHANGED': + case 'SHARED_WALLET_POLICY_CHANGE_APPROVED': + return + case 'SHARED_WALLET_POLICY_CHANGE_PROPOSED': + return + case 'SHARED_WALLET_POLICY_CHANGE_DECLINED': + return // Contact notifications case 'CONTACT_REQUEST_RECEIVED': @@ -350,6 +373,54 @@ const NotificationCenter: React.FC = ({ isOpen, onClose } } + // The notification carries membershipId directly (embedded at creation time) so + // responding doesn't depend on the separately-fetched pendingSharedWalletInvitations + // list, which only loads once when the notification bell mounts and can be stale for + // any invite that arrived afterward - see fallback below for older notifications + // created before this field existed. + const handleSharedWalletInvitationResponse = async (notification: Notification, action: 'accept' | 'reject') => { + const sharedWalletId = notification.data?.sharedWalletId as string + let resolvedMembershipId = notification.data?.membershipId as string | undefined + + if (!resolvedMembershipId) { + const invitation = pendingSharedWalletInvitations?.data?.find((inv: any) => inv.sharedWalletId === sharedWalletId) + if (!invitation) { + toast({ + title: "Error", + description: "This invitation is no longer available", + variant: "destructive", + }) + return + } + resolvedMembershipId = invitation.id as string + } + const membershipId: string = resolvedMembershipId + + setProcessingRequestId(membershipId) + try { + await respondToSharedWalletInvitation({ + sharedWalletId, + membershipId, + action, + }).unwrap() + + toast({ + title: "Success", + description: `Shared wallet invitation ${action === 'accept' ? 'accepted' : 'declined'} successfully`, + }) + + refetchSharedWalletInvitations() + } catch (error: any) { + toast({ + title: "Error", + description: error?.data?.message || `Failed to ${action === 'accept' ? 'accept' : 'decline'} invitation`, + variant: "destructive", + }) + } finally { + setProcessingRequestId(null) + } + } + const handleNotificationClick = (notification: Notification) => { if (!notification.isRead) { markAsRead(notification.id) @@ -376,8 +447,10 @@ const NotificationCenter: React.FC = ({ isOpen, onClose router.push(`/groups/${notification.data.groupId}`) onClose() } - // Handle navigation for shared wallet notifications - else if (notification.data?.sharedWalletId && notification.type.startsWith('SHARED_WALLET_')) { + // Handle navigation for shared wallet notifications - SHARED_WALLET_INVITATION is + // excluded since it has its own inline Accept/Decline buttons below, same as + // GROUP_INVITATION above. + else if (notification.data?.sharedWalletId && notification.type.startsWith('SHARED_WALLET_') && notification.type !== 'SHARED_WALLET_INVITATION') { router.push(`/wallets/shared/${notification.data.sharedWalletId}`) onClose() } @@ -820,6 +893,39 @@ const NotificationCenter: React.FC = ({ isOpen, onClose
)} + + {/* Accept/Decline for shared wallet invitations - uses the + membershipId embedded directly in the notification data */} + {notification.type === 'SHARED_WALLET_INVITATION' && + notification.data?.sharedWalletId && ( +
+ + +
+ )}
)) diff --git a/components/transfer/PinEntry.tsx b/components/transfer/PinEntry.tsx index 4962e89..a541260 100644 --- a/components/transfer/PinEntry.tsx +++ b/components/transfer/PinEntry.tsx @@ -25,20 +25,20 @@ const PinEntry = ({ pin, setPin, showPin, setShowPin, error }: PinEntryProps) => }; return ( -
-
-
- +
+
+
+
-

Security Check

-

- Please enter your secure 4-digit PIN to authorize this transfer. +

Security Check

+

+ Enter your 4-digit PIN to authorize this transfer.

{/* PIN Display Containers */} -
+
{[...Array(maxLength)].map((_, i) => (
? "bg-brand-green dark:bg-brand-gold border-transparent scale-110 shadow-lg" : "bg-gray-50 dark:bg-darkBg-main border-gray-100 dark:border-darkBorder-light", showPin - ? "w-10 h-12 rounded-2xl" + ? "w-8 h-9 rounded-xl" : i < pin.length - ? "w-4 h-4 rounded-full" - : "w-4 h-4 rounded-full" + ? "w-3 h-3 rounded-full" + : "w-3 h-3 rounded-full" )} > {showPin && i < pin.length && ( - + {pin[i]} )} @@ -64,24 +64,24 @@ const PinEntry = ({ pin, setPin, showPin, setShowPin, error }: PinEntryProps) =>
{error && ( -

+

{error}

)} {/* Numeric Keypad */} -
+
{numbers.map((n, i) => ( ))}
@@ -89,7 +89,7 @@ const PinEntry = ({ pin, setPin, showPin, setShowPin, error }: PinEntryProps) =>