Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 41 additions & 74 deletions app/home/transfer/batch/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Set<string>>(new Set());
const [recipients, setRecipients] = useState<BatchContact[]>([]);
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [splitTotal, setSplitTotal] = useState("");
const [schedule, setSchedule] = useState<ScheduleState>(defaultScheduleState);
Expand All @@ -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<string, BatchContact> = {};
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 () => {
Expand Down Expand Up @@ -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("");
};
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -245,7 +237,6 @@ const BatchTransferPage = () => {
senderOrganizationId,
recipients,
pin,
description: "Batch transfer",
scheduledFor: scheduledDateTime.toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
recurrence,
Expand Down Expand Up @@ -273,7 +264,6 @@ const BatchTransferPage = () => {
senderOrganizationId,
recipients,
pin,
description: "Batch transfer",
idempotencyKey,
});

Expand Down Expand Up @@ -370,33 +360,17 @@ const BatchTransferPage = () => {
</div>
)}

<div className="relative mb-4">
<input
type="text"
placeholder="Search contacts"
value={searchQuery}
onChange={(e) => 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"
/>
</div>

<div className="space-y-2 max-h-[420px] overflow-y-auto mb-4">
{isContactsLoading ? (
<div className="py-10 text-center text-gray-500">Loading contacts...</div>
) : filteredContacts.length > 0 ? (
filteredContacts.map((contact) => (
<BatchRecipientRow
key={contact.id}
contact={contact}
isSelected={selectedIds.has(contact.id)}
amount={amounts[contact.id] || ""}
onToggle={() => toggleContact(contact.id)}
onAmountChange={(value) => setAmountFor(contact.id, value)}
/>
))
) : (
<div className="py-10 text-center text-gray-500">No contacts found.</div>
)}
{recipients.map((contact) => (
<BatchRecipientRow
key={contact.id}
contact={contact}
isSelected
amount={amounts[contact.id] || ""}
onToggle={() => removeRecipient(contact.id)}
onAmountChange={(value) => setAmountFor(contact.id, value)}
/>
))}
</div>

{selectedContacts.length > 0 && (
Expand Down Expand Up @@ -536,13 +510,6 @@ const BatchTransferPage = () => {
</button>
</div>
)}

{!isContactsLoading && contacts.length === 0 && step === 1 && (
<div className="mt-4 flex items-start gap-2.5 p-4 bg-gray-50 dark:bg-darkBg-interactive rounded-2xl text-sm text-gray-500 dark:text-gray-400">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
Add some contacts first from the main transfer page before sending to multiple people.
</div>
)}
</div>
</div>
</main>
Expand Down
34 changes: 34 additions & 0 deletions app/wallets/shared/[sharedWalletId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`flex min-h-screen bg-gray-50 ${accent.darkBgPage}`}>
<Navigation />
<div className={cn("flex-1 transition-all duration-300", isExpanded ? "lg:ml-64" : "lg:ml-20")}>
<div className="px-4 sm:px-6 lg:px-8 py-4"><Header /></div>
<div className="px-4 sm:px-6 lg:px-8 pb-24 lg:pb-6">
<div className="max-w-3xl mx-auto">
<div className="mb-6">
<BackButton className="mb-4" />
</div>
{sharedWalletId && <SharedWalletDetail sharedWalletId={sharedWalletId} />}
</div>
</div>
</div>
</div>
)
}
144 changes: 144 additions & 0 deletions components/Wallet/AddSharedWalletMemberModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([])
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 ? "Invitation sent" : `${selected.length} invitations sent` })
onClose()
} catch (error: any) {
toast({
title: "Could not send invitation",
description: error?.data?.message || "Something went wrong",
variant: "destructive",
})
}
}

return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Invite members</DialogTitle>
<DialogDescription className="text-gray-600 dark:text-gray-400 text-sm">
Pick from your contacts who aren&apos;t already in this wallet. They&apos;ll need to accept before joining.
</DialogDescription>
</DialogHeader>

<div className="py-2">
{isLoadingContacts ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin mr-2" /> Loading contacts...
</div>
) : contacts.length === 0 ? (
<div className="text-center py-6 text-sm text-gray-500 flex flex-col items-center gap-2">
<Users className="w-5 h-5 text-gray-400" />
No contacts available to add.
</div>
) : (
<div className="max-h-64 overflow-y-auto border rounded-md p-2">
{contacts.map((contact) => (
<div key={contact.id} className="flex items-center py-2">
<Checkbox
id={`add-member-contact-${contact.id}`}
checked={selected.includes(contact.otherUser.id)}
onCheckedChange={() => toggle(contact.otherUser.id)}
className="mr-2"
/>
<label
htmlFor={`add-member-contact-${contact.id}`}
className="flex items-center cursor-pointer flex-1"
onClick={() => toggle(contact.otherUser.id)}
>
<Avatar className="h-8 w-8 mr-2">
<AvatarImage
src={contact.otherUser.avatar || "/placeholder.svg"}
alt={`${contact.otherUser.firstName} ${contact.otherUser.lastName}`}
/>
<AvatarFallback>
{(contact.otherUser.firstName || '').charAt(0).toUpperCase()}
{(contact.otherUser.lastName || '').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="font-medium text-sm">
{contact.otherUser.firstName} {contact.otherUser.lastName}
</span>
</label>
</div>
))}
</div>
)}
</div>

<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={handleSubmit}
disabled={isAdding || selected.length === 0}
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 && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Invite {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading
Loading