diff --git a/packages/nextjs/app/contact-book/page.tsx b/packages/nextjs/app/contact-book/page.tsx index 6c29332..9614232 100644 --- a/packages/nextjs/app/contact-book/page.tsx +++ b/packages/nextjs/app/contact-book/page.tsx @@ -4,12 +4,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { Contact, ContactGroup } from "@polypay/shared"; -import { Search } from "lucide-react"; +import { ChevronDown, Search } from "lucide-react"; import { ContactDetailDrawer } from "~~/components/contact-book/ContactDetailDrawer"; import { ContactList } from "~~/components/contact-book/ContactList"; import { EditContact } from "~~/components/contact-book/Editcontact"; import { modalManager } from "~~/components/modals/ModalLayout"; import { useContacts, useGroups } from "~~/hooks"; +import { useClickOutside } from "~~/hooks/useClickOutside"; import { useAccountStore } from "~~/services/store"; import { formatAddress } from "~~/utils/format"; @@ -23,7 +24,10 @@ export default function AddressBookPage() { const [searchTerm, setSearchTerm] = useState(""); const [editing, setEditing] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [batchDropdownOpen, setBatchDropdownOpen] = useState(false); const searchInputRef = useRef(null); + const batchDropdownRef = useRef(null); + useClickOutside(batchDropdownRef, () => setBatchDropdownOpen(false), { isActive: batchDropdownOpen }); const { data: groups = [], refetch: refetchGroups } = useGroups(accountId); const { @@ -131,17 +135,40 @@ export default function AddressBookPage() { New group New group - +
+ + {batchDropdownOpen && ( +
+ + +
+ )} +
+
+ {tx.type === TxType.BATCH && batchData && ( -
- )} + )} + {myVoteStatus === null && ( + <> + + + + )} + ); diff --git a/packages/nextjs/components/Dashboard/TransactionRow/index.tsx b/packages/nextjs/components/Dashboard/TransactionRow/index.tsx index 47f579c..0632547 100644 --- a/packages/nextjs/components/Dashboard/TransactionRow/index.tsx +++ b/packages/nextjs/components/Dashboard/TransactionRow/index.tsx @@ -127,7 +127,9 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) { {getTxTypeLabel(tx.type)} {!expanded && } -
e.stopPropagation()}>{renderRightSide()}
+
e.stopPropagation()}> + {renderRightSide()} +
{expanded && ( @@ -140,6 +142,7 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) { loading={loading} initiatorCommitment={initiatorCommitment} initiatorName={initiatorName} + batchData={tx.batchData} /> void; accountId?: string; + mode?: "manual" | "csv"; + initialBatchItems?: BatchContactEntry[]; [key: string]: any; } @@ -33,16 +37,20 @@ export default function CreateBatchFromContactsModal({ isOpen, onClose, accountId, + mode = "manual", + initialBatchItems, }: CreateBatchFromContactsModalProps) { const [step, setStep] = useState(1); const [selectedContactIds, setSelectedContactIds] = useState>(new Set()); const [selectedGroupId, setSelectedGroupId] = useState(null); const [batchEntries, setBatchEntries] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const hasAppliedInitialItems = useRef(false); 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 { tokens, nativeEth, chainId } = useNetworkTokens(); const { mutateAsync: createBatchItem } = useCreateBatchItem(); const { proposeBatch, @@ -52,7 +60,6 @@ export default function CreateBatchFromContactsModal({ totalSteps, } = useBatchTransaction({ onSuccess: () => { - notification.success("Batch transaction created!"); handleReset(); onClose(); }, @@ -60,11 +67,22 @@ export default function CreateBatchFromContactsModal({ const defaultToken = nativeEth || tokens[0]; + // Skip to step 2 when initialBatchItems is provided (duplicate flow) + useEffect(() => { + if (isOpen && initialBatchItems && initialBatchItems.length > 0 && !hasAppliedInitialItems.current) { + setBatchEntries(initialBatchItems); + setStep(2); + hasAppliedInitialItems.current = true; + } + }, [isOpen, initialBatchItems]); + const handleReset = () => { setStep(1); setSelectedContactIds(new Set()); setSelectedGroupId(null); setBatchEntries([]); + setCsvFile(null); + hasAppliedInitialItems.current = false; }; const handleClose = () => { @@ -108,6 +126,55 @@ export default function CreateBatchFromContactsModal({ setStep(2); }; + // CSV continue handler + const handleCsvContinue = () => { + if (!csvFile) return; + const reader = new FileReader(); + reader.onload = e => { + const text = e.target?.result as string; + if (!text) return; + + const { validEntries, invalidCount } = parseBatchCsv(text, chainId); + + if (invalidCount > 0) { + notification.error(`${invalidCount} row(s) skipped (invalid address, amount, or token)`); + } + + if (validEntries.length === 0) { + notification.error("No valid rows found"); + return; + } + + const entries: BatchContactEntry[] = validEntries.map(entry => ({ + contact: { + id: crypto.randomUUID(), + name: "", + address: entry.address, + accountId: "", + groups: [], + createdAt: "", + updatedAt: "", + } as Contact, + amount: entry.amount, + tokenAddress: entry.tokenAddress, + isSynthetic: true, + })); + + setBatchEntries(entries); + setStep(2); + }; + reader.readAsText(csvFile); + }; + + // Back button logic + const handleBack = () => { + if (step === 2 && initialBatchItems && initialBatchItems.length > 0) { + handleClose(); + return; + } + setStep((step - 1) as Step); + }; + // Step 2: Amount & Token const updateEntryAmount = (index: number, amount: string) => { setBatchEntries(prev => prev.map((entry, i) => (i === index ? { ...entry, amount } : entry))); @@ -143,7 +210,7 @@ export default function CreateBatchFromContactsModal({ recipient: entry.contact.address, amount: amountInSmallestUnit, tokenAddress: entry.tokenAddress === ZERO_ADDRESS ? undefined : entry.tokenAddress, - contactId: entry.contact.id, + contactId: entry.isSynthetic ? undefined : entry.contact.id, }; return createBatchItem(dto); }), @@ -154,118 +221,323 @@ export default function CreateBatchFromContactsModal({ } }; - const title = step === 1 ? "Choose contact" : step === 2 ? "Add to batch" : "Transactions summary"; + const isCsvMode = mode === "csv"; + const title = + step === 1 ? (isCsvMode ? "Upload CSV" : "Choose contact") : step === 2 ? "Add to batch" : "Transactions summary"; return ( - {/* Header */} -
- {step > 1 ? ( - + ) : ( +
+ )} +

{title}

+ - ) : ( -
- )} -

{title}

- -
- - {/* Content */} -
- {step === 1 && ( - - )} - - {step === 2 && ( - - )} +
- {step === 3 && } -
+ {/* Content */} +
+ {step === 1 && + (isCsvMode ? ( + setCsvFile(null)} /> + ) : ( + + ))} - {/* Footer */} -
- -
- {step === 1 && ( - - )} {step === 2 && ( - + )} - {step === 3 && ( -
- {isProposing && loadingState && loadingStep > 0 && ( -
-
- Step {loadingStep} of {totalSteps} — {loadingState} -
-
-
-
-
- )} + + {step === 3 && } +
+ + {/* Footer */} +
+ +
+ {step === 1 && + (isCsvMode ? ( + + ) : ( + + ))} + {step === 2 && ( -
- )} + )} + {step === 3 && ( +
+ {isProposing && loadingState && loadingStep > 0 && ( +
+
+ Step {loadingStep} of {totalSteps} — {loadingState} +
+
+
+
+
+ )} + +
+ )} +
); } -// --- Step 1: Choose Contact --- +// --- Step 1a: Upload CSV --- +type CsvUploadState = "idle" | "importing" | "loaded"; + +function StepUploadCsv({ + onFileReady, + onFileRemoved, +}: { + onFileReady: (file: File) => void; + onFileRemoved: () => void; +}) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [uploadState, setUploadState] = useState("idle"); + const [file, setFile] = useState(null); + const [importProgress, setImportProgress] = useState(0); + const [templateDownloading, setTemplateDownloading] = useState(false); + const [templateProgress, setTemplateProgress] = useState(0); + const importTimerRef = useRef(null); + + const handleFile = (selectedFile: File) => { + if (!selectedFile.name.endsWith(".csv")) { + notification.error("Please upload a CSV file"); + return; + } + setFile(selectedFile); + setUploadState("importing"); + setImportProgress(0); + + // Simulate import progress (local file read is near-instant) + let progress = 0; + importTimerRef.current = setInterval(() => { + progress += 20; + setImportProgress(Math.min(progress, 100)); + if (progress >= 100) { + if (importTimerRef.current) clearInterval(importTimerRef.current); + setUploadState("loaded"); + onFileReady(selectedFile); + } + }, 100); + }; + + const handleCancelImport = () => { + if (importTimerRef.current) clearInterval(importTimerRef.current); + setUploadState("idle"); + setFile(null); + setImportProgress(0); + onFileRemoved(); + }; + + const handleRemoveFile = () => { + setUploadState("idle"); + setFile(null); + setImportProgress(0); + onFileRemoved(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) handleFile(droppedFile); + }; + + const handleDownloadTemplate = () => { + setTemplateDownloading(true); + setTemplateProgress(0); + + // Simulate brief download progress + let progress = 0; + const timer = setInterval(() => { + progress += 25; + setTemplateProgress(Math.min(progress, 100)); + if (progress >= 100) { + clearInterval(timer); + setTemplateDownloading(false); + setTemplateProgress(0); + } + }, 150); + + // Trigger actual download + const link = document.createElement("a"); + link.href = "/templates/batch-template.csv"; + link.download = "batch-template.csv"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (importTimerRef.current) clearInterval(importTimerRef.current); + }; + }, []); + + return ( +
+ {/* Drop zone */} +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onClick={() => uploadState === "idle" && fileInputRef.current?.click()} + className={`flex flex-col items-center justify-center gap-4 border border-dashed rounded-2xl h-[240px] transition-colors ${ + uploadState !== "idle" + ? "border-grey-300 bg-grey-50" + : isDragging + ? "border-main-pink bg-pink-50 cursor-pointer" + : "border-grey-300 bg-grey-50 cursor-pointer hover:border-grey-400" + }`} + > + Upload +
+

Import CSV file

+

Drop the file or click here to choose file

+
+ { + const selected = e.target.files?.[0]; + if (selected) handleFile(selected); + e.target.value = ""; + }} + /> +
+ + {/* File info card - importing state */} + {uploadState === "importing" && file && ( +
+ Uploading +
+

{file.name}

+
+ {importProgress}% +
+
+
+ +
+ )} + + {/* File info card - loaded state */} + {uploadState === "loaded" && file && ( +
+ CSV +
+

{file.name}

+

{formatFileSize(file.size)}

+
+ +
+ )} + + {/* Download template link */} +
+ Download + + {templateDownloading && ( +
+ {templateProgress}% +
+
+ )} +
+
+ ); +} + +// --- Step 1b: Choose Contact --- function StepChooseContact({ contacts, groups, @@ -395,10 +667,19 @@ function StepAddToBatch({ Transfer avatar
- {entry.contact.name} - - {formatAddress(entry.contact.address, { start: 4, end: 4 })} - + {!entry.contact.name && ( + + {formatAddress(entry.contact.address, { start: 4, end: 4 })} + + )} + {entry.contact.name && ( + <> + {entry.contact.name} + + {formatAddress(entry.contact.address, { start: 4, end: 4 })} + + + )}
diff --git a/packages/nextjs/public/icons/batch/close-circle.svg b/packages/nextjs/public/icons/batch/close-circle.svg new file mode 100644 index 0000000..0fb6e12 --- /dev/null +++ b/packages/nextjs/public/icons/batch/close-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/nextjs/public/icons/batch/csv-file.svg b/packages/nextjs/public/icons/batch/csv-file.svg new file mode 100644 index 0000000..db411a1 --- /dev/null +++ b/packages/nextjs/public/icons/batch/csv-file.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/nextjs/public/icons/batch/document-download.svg b/packages/nextjs/public/icons/batch/document-download.svg new file mode 100644 index 0000000..7a50d18 --- /dev/null +++ b/packages/nextjs/public/icons/batch/document-download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/public/icons/batch/export-upload.svg b/packages/nextjs/public/icons/batch/export-upload.svg new file mode 100644 index 0000000..4b3650f --- /dev/null +++ b/packages/nextjs/public/icons/batch/export-upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/public/icons/batch/user-edit.svg b/packages/nextjs/public/icons/batch/user-edit.svg new file mode 100644 index 0000000..759ba2b --- /dev/null +++ b/packages/nextjs/public/icons/batch/user-edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/public/templates/batch-template.csv b/packages/nextjs/public/templates/batch-template.csv new file mode 100644 index 0000000..8b890b8 --- /dev/null +++ b/packages/nextjs/public/templates/batch-template.csv @@ -0,0 +1 @@ +address,amount,token diff --git a/packages/nextjs/utils/parseBatchCsv.ts b/packages/nextjs/utils/parseBatchCsv.ts new file mode 100644 index 0000000..9b71d98 --- /dev/null +++ b/packages/nextjs/utils/parseBatchCsv.ts @@ -0,0 +1,84 @@ +import { getTokenBySymbol } from "@polypay/shared"; +import { isAddress } from "viem"; + +interface ParsedBatchEntry { + address: string; + amount: string; + tokenAddress: string; +} + +interface ParseBatchCsvResult { + validEntries: ParsedBatchEntry[]; + invalidCount: number; +} + +export function parseBatchCsv(csvText: string, chainId: number): ParseBatchCsvResult { + // Strip BOM + const cleaned = csvText.replace(/^\uFEFF/, ""); + const lines = cleaned.split(/\r\n|\r|\n/).filter(line => line.trim() !== ""); + + if (lines.length === 0) { + return { validEntries: [], invalidCount: 0 }; + } + + // Skip header row: if the first field is not a valid Ethereum address, treat it as a header + let startIndex = 0; + const firstField = lines[0].split(",")[0].trim(); + if (!isAddress(firstField)) { + startIndex = 1; + } + + const validEntries: ParsedBatchEntry[] = []; + let invalidCount = 0; + + for (let i = startIndex; i < lines.length; i++) { + const parts = lines[i].split(",").map(p => p.trim()); + if (parts.length < 3) { + invalidCount++; + continue; + } + + const [address, amountStr, symbol] = parts; + + if (!isAddress(address)) { + invalidCount++; + continue; + } + + const amount = parseFloat(amountStr); + if (!Number.isFinite(amount) || amount <= 0) { + invalidCount++; + continue; + } + + try { + const token = getTokenBySymbol(symbol, chainId); + // Check if the resolved token matches the requested symbol (getTokenBySymbol falls back to native) + if (token.symbol.toLowerCase() !== symbol.toLowerCase()) { + invalidCount++; + continue; + } + validEntries.push({ address, amount: amountStr, tokenAddress: token.address }); + } catch { + invalidCount++; + } + } + + // Merge entries with same address + tokenAddress by summing amounts + const mergeKey = (entry: ParsedBatchEntry) => `${entry.address.toLowerCase()}_${entry.tokenAddress.toLowerCase()}`; + const mergedMap = new Map(); + + for (const entry of validEntries) { + const key = mergeKey(entry); + const existing = mergedMap.get(key); + if (existing) { + // Use toFixed(18) to avoid floating-point precision issues (e.g., 0.1 + 0.2 = 0.30000000000000004) + const sum = parseFloat(existing.amount) + parseFloat(entry.amount); + existing.amount = parseFloat(sum.toFixed(18)).toString(); + } else { + mergedMap.set(key, { ...entry }); + } + } + + return { validEntries: Array.from(mergedMap.values()), invalidCount }; +}