diff --git a/packages/nextjs/app/contact-book/page.tsx b/packages/nextjs/app/contact-book/page.tsx index 20509837..6c293322 100644 --- a/packages/nextjs/app/contact-book/page.tsx +++ b/packages/nextjs/app/contact-book/page.tsx @@ -119,7 +119,7 @@ export default function AddressBookPage() {
+
diff --git a/packages/nextjs/components/Batch/TransactionSummary.tsx b/packages/nextjs/components/Batch/TransactionSummary.tsx index a27b84b0..c3f44842 100644 --- a/packages/nextjs/components/Batch/TransactionSummary.tsx +++ b/packages/nextjs/components/Batch/TransactionSummary.tsx @@ -139,7 +139,7 @@ const TransactionSummary: React.FC = ({ className="flex items-center justify-center px-5 py-3 rounded-lg w-full disabled:opacity-50 bg-main-pink" > - {isLoading ? loadingState || "Processing..." : `Submit batch`} + {isLoading ? loadingState || "Processing..." : `Propose batch`} diff --git a/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx new file mode 100644 index 00000000..001effc1 --- /dev/null +++ b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx @@ -0,0 +1,482 @@ +"use client"; + +import { useCallback, useState } from "react"; +import Image from "next/image"; +import { Contact, ContactGroup, CreateBatchItemDto, ZERO_ADDRESS } from "@polypay/shared"; +import { ArrowLeft, GripVertical, X } from "lucide-react"; +import { parseUnits } from "viem"; +import { Checkbox } from "~~/components/Common"; +import ModalContainer from "~~/components/modals/ModalContainer"; +import { TokenPillPopover } from "~~/components/popovers/TokenPillPopover"; +import { useContacts, useCreateBatchItem, useGroups } from "~~/hooks"; +import { useBatchTransaction } from "~~/hooks"; +import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; +import { formatAddress } from "~~/utils/format"; +import { notification } from "~~/utils/scaffold-eth"; + +interface BatchContactEntry { + contact: Contact; + amount: string; + tokenAddress: string; +} + +interface CreateBatchFromContactsModalProps { + isOpen: boolean; + onClose: () => void; + accountId?: string; + [key: string]: any; +} + +type Step = 1 | 2 | 3; + +export default function CreateBatchFromContactsModal({ + isOpen, + onClose, + accountId, +}: CreateBatchFromContactsModalProps) { + const [step, setStep] = useState(1); + const [selectedContactIds, setSelectedContactIds] = useState>(new Set()); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [batchEntries, setBatchEntries] = useState([]); + + const { data: contacts = [] } = useContacts(accountId || null, selectedGroupId || undefined); + const { data: allContacts = [] } = useContacts(accountId || null); + const { data: groups = [] } = useGroups(accountId || null); + const { tokens, nativeEth } = useNetworkTokens(); + const { mutateAsync: createBatchItem } = useCreateBatchItem(); + const { + proposeBatch, + isLoading: isProposing, + loadingState, + loadingStep, + totalSteps, + } = useBatchTransaction({ + onSuccess: () => { + notification.success("Batch transaction created!"); + handleReset(); + onClose(); + }, + }); + + const defaultToken = nativeEth || tokens[0]; + + const handleReset = () => { + setStep(1); + setSelectedContactIds(new Set()); + setSelectedGroupId(null); + setBatchEntries([]); + }; + + const handleClose = () => { + if (isProposing) return; + handleReset(); + onClose(); + }; + + // Step 1: Contact Selection + const toggleContact = useCallback((contactId: string) => { + setSelectedContactIds(prev => { + const next = new Set(prev); + if (next.has(contactId)) { + next.delete(contactId); + } else { + next.add(contactId); + } + return next; + }); + }, []); + + const handleSelectAll = useCallback(() => { + if (selectedContactIds.size === contacts.length && contacts.length > 0) { + setSelectedContactIds(new Set()); + } else { + setSelectedContactIds(new Set(contacts.map(c => c.id))); + } + }, [selectedContactIds.size, contacts]); + + const allSelected = contacts.length > 0 && selectedContactIds.size === contacts.length; + + const goToStep2 = () => { + const selectedContacts = allContacts.filter(c => selectedContactIds.has(c.id)); + setBatchEntries( + selectedContacts.map(contact => ({ + contact, + amount: "", + tokenAddress: defaultToken?.address || ZERO_ADDRESS, + })), + ); + setStep(2); + }; + + // Step 2: Amount & Token + const updateEntryAmount = (index: number, amount: string) => { + setBatchEntries(prev => prev.map((entry, i) => (i === index ? { ...entry, amount } : entry))); + }; + + const updateEntryToken = (index: number, tokenAddress: string) => { + setBatchEntries(prev => prev.map((entry, i) => (i === index ? { ...entry, tokenAddress } : entry))); + }; + + const removeEntry = (index: number) => { + setBatchEntries(prev => prev.filter((_, i) => i !== index)); + }; + + const allEntriesFilled = batchEntries.length > 0 && batchEntries.every(e => e.amount && parseFloat(e.amount) > 0); + + const goToStep3 = () => { + setStep(3); + }; + + // Step 3: Execute + const resolveToken = (tokenAddress: string) => { + return tokens.find(t => t.address === tokenAddress) || defaultToken; + }; + + const handleProposeBatch = async () => { + try { + const createdItems = await Promise.all( + batchEntries.map(entry => { + const token = resolveToken(entry.tokenAddress); + const amountInSmallestUnit = parseUnits(entry.amount, token.decimals).toString(); + + const dto: CreateBatchItemDto = { + recipient: entry.contact.address, + amount: amountInSmallestUnit, + tokenAddress: entry.tokenAddress === ZERO_ADDRESS ? undefined : entry.tokenAddress, + contactId: entry.contact.id, + }; + return createBatchItem(dto); + }), + ); + await proposeBatch(createdItems); + } catch { + // Error is handled inside proposeBatch / createBatchItem + } + }; + + const title = step === 1 ? "Choose contact" : step === 2 ? "Add to batch" : "Transactions summary"; + + return ( + + {/* Header */} +
+ {step > 1 ? ( + + ) : ( +
+ )} +

{title}

+ +
+ + {/* Content */} +
+ {step === 1 && ( + + )} + + {step === 2 && ( + + )} + + {step === 3 && } +
+ + {/* Footer */} +
+ +
+ {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( +
+ {isProposing && loadingState && loadingStep > 0 && ( +
+
+ Step {loadingStep} of {totalSteps} — {loadingState} +
+
+
+
+
+ )} + +
+ )} +
+
+ + ); +} + +// --- Step 1: Choose Contact --- +function StepChooseContact({ + contacts, + groups, + selectedGroupId, + selectedContactIds, + onSelectGroup, + onToggleContact, + onSelectAll, + allSelected, +}: { + contacts: Contact[]; + groups: ContactGroup[]; + selectedGroupId: string | null; + selectedContactIds: Set; + onSelectGroup: (id: string | null) => void; + onToggleContact: (id: string) => void; + onSelectAll: () => void; + allSelected: boolean; +}) { + return ( +
+ {/* Group filter tabs */} +
+ + {groups.map((group: ContactGroup) => ( + + ))} +
+ + {/* Select all / count */} +
+ + {selectedContactIds.size} selected +
+ + {/* Contact list */} +
+ {contacts.map((contact: Contact) => { + const isSelected = selectedContactIds.has(contact.id); + return ( +
+
onToggleContact(contact.id)}> + + avatar + {contact.name} +
+
+ + {formatAddress(contact.address, { start: 4, end: 4 })} + + +
+
+ ); + })} +
+
+ ); +} + +// --- Step 2: Add to Batch --- +function StepAddToBatch({ + entries, + resolveToken, + onUpdateAmount, + onUpdateToken, + onRemove, +}: { + entries: BatchContactEntry[]; + resolveToken: (address: string) => any; + onUpdateAmount: (index: number, amount: string) => void; + onUpdateToken: (index: number, tokenAddress: string) => void; + onRemove: (index: number) => void; +}) { + return ( +
+ {entries.map((entry, index) => { + const selectedToken = resolveToken(entry.tokenAddress); + return ( +
+
+ + Transfer + avatar +
+ {entry.contact.name} + + {formatAddress(entry.contact.address, { start: 4, end: 4 })} + +
+
+ +
+
+ { + const val = e.target.value; + if (val === "" || /^\d*\.?\d*$/.test(val)) { + onUpdateAmount(index, val); + } + }} + className="flex-1 text-base font-medium outline-none bg-transparent min-w-0" + /> + onUpdateToken(index, tokenAddress)} + /> +
+ +
+
+ ); + })} +
+ ); +} + +// --- Step 3: Transaction Summary --- +function StepTransactionSummary({ + entries, + resolveToken, +}: { + entries: BatchContactEntry[]; + resolveToken: (address: string) => any; +}) { + return ( +
+

+ Please review the information below and confirm to make the transaction. +

+
+ {entries.map((entry, index) => { + const token = resolveToken(entry.tokenAddress); + return ( +
+ Transfer +
+
+ {token.symbol} + + {entry.amount} {token.symbol} + +
+ arrow +
+ avatar + + {entry.contact.name} ({formatAddress(entry.contact.address, { start: 4, end: 4 })}) + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/packages/nextjs/components/modals/ModalLayout.tsx b/packages/nextjs/components/modals/ModalLayout.tsx index f378e129..07eef004 100644 --- a/packages/nextjs/components/modals/ModalLayout.tsx +++ b/packages/nextjs/components/modals/ModalLayout.tsx @@ -54,6 +54,7 @@ const modals: ModalRegistry = { disclaimer: dynamic(() => import("./DisclaimerModal"), { ssr: false }), claimReward: dynamic(() => import("./ClaimRewardModal"), { ssr: false }), questIntro: dynamic(() => import("./QuestIntroModal"), { ssr: false }), + createBatchFromContacts: dynamic(() => import("./CreateBatchFromContactsModal"), { ssr: false }), }; type ModalInstance = { diff --git a/packages/nextjs/public/contact-book/create-batch.svg b/packages/nextjs/public/contact-book/create-batch.svg new file mode 100644 index 00000000..41fe23b6 --- /dev/null +++ b/packages/nextjs/public/contact-book/create-batch.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/nextjs/public/contact-book/new-contact.svg b/packages/nextjs/public/contact-book/new-contact.svg new file mode 100644 index 00000000..f725cc53 --- /dev/null +++ b/packages/nextjs/public/contact-book/new-contact.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/public/contact-book/new-group.svg b/packages/nextjs/public/contact-book/new-group.svg new file mode 100644 index 00000000..2a5db310 --- /dev/null +++ b/packages/nextjs/public/contact-book/new-group.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/nextjs/public/contact-book/trash.svg b/packages/nextjs/public/contact-book/trash.svg new file mode 100644 index 00000000..ab9f9173 --- /dev/null +++ b/packages/nextjs/public/contact-book/trash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/styles/globals.css b/packages/nextjs/styles/globals.css index d4de78b5..b8eb7ff2 100644 --- a/packages/nextjs/styles/globals.css +++ b/packages/nextjs/styles/globals.css @@ -194,7 +194,6 @@ input:-webkit-autofill:hover, input:-webkit-autofill:focus, input:-webkit-autofill:active { - -webkit-box-shadow: 0 0 0 1000px white inset !important; -webkit-text-fill-color: var(--color-main-black) !important; transition: 5000s ease-in-out 0s; } diff --git a/packages/nextjs/types/modal.ts b/packages/nextjs/types/modal.ts index 32e8698c..08e230aa 100644 --- a/packages/nextjs/types/modal.ts +++ b/packages/nextjs/types/modal.ts @@ -30,4 +30,5 @@ export type ModalName = | "switchAccount" | "disclaimer" | "claimReward" - | "questIntro"; + | "questIntro" + | "createBatchFromContacts";