From efdfc7da32967c70cbeb98fea6a7135477fabaa5 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 19 Mar 2026 14:43:51 +0700 Subject: [PATCH 1/2] feat: add CSV upload and duplicate batch flows for batch creation --- packages/nextjs/app/contact-book/page.tsx | 51 ++- .../Dashboard/TransactionRow/BatchRowMenu.tsx | 84 +++++ .../Dashboard/TransactionRow/index.tsx | 8 +- .../modals/CreateBatchFromContactsModal.tsx | 330 +++++++++++++----- packages/nextjs/utils/parseBatchCsv.ts | 68 ++++ 5 files changed, 432 insertions(+), 109 deletions(-) create mode 100644 packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx create mode 100644 packages/nextjs/utils/parseBatchCsv.ts diff --git a/packages/nextjs/app/contact-book/page.tsx b/packages/nextjs/app/contact-book/page.tsx index 6c29332..b12f7e5 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, Upload, Users } 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 && ( +
+ + +
+ )} +
+ {open && ( +
+ +
+ )} + + ); +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/index.tsx b/packages/nextjs/components/Dashboard/TransactionRow/index.tsx index 47f579c..0cdfdbf 100644 --- a/packages/nextjs/components/Dashboard/TransactionRow/index.tsx +++ b/packages/nextjs/components/Dashboard/TransactionRow/index.tsx @@ -2,11 +2,12 @@ import React, { useEffect, useState } from "react"; import { ActionButtons, AwaitingBadge, StatusBadge } from "./Badges"; +import { BatchRowMenu } from "./BatchRowMenu"; import { SignerList } from "./SignerList"; import { TxDetails } from "./TxDetails"; import { TxHeader } from "./TxHeader"; import { getTxTypeLabel } from "./utils"; -import { TxStatus } from "@polypay/shared"; +import { TxStatus, TxType } from "@polypay/shared"; import { ChevronDown, ChevronRight } from "lucide-react"; import { TransactionRowData, useTransactionVote, useWalletCommitments, useWalletThreshold } from "~~/hooks"; import { formatAddress } from "~~/utils/format"; @@ -127,7 +128,10 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) { {getTxTypeLabel(tx.type)} {!expanded && } -
e.stopPropagation()}>{renderRightSide()}
+
e.stopPropagation()}> + {renderRightSide()} + {tx.type === TxType.BATCH && tx.batchData && } +
{expanded && ( diff --git a/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx index 001effc..283046c 100644 --- a/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx +++ b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx @@ -1,9 +1,9 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, 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 { ArrowLeft, GripVertical, Upload, X } from "lucide-react"; import { parseUnits } from "viem"; import { Checkbox } from "~~/components/Common"; import ModalContainer from "~~/components/modals/ModalContainer"; @@ -12,18 +12,22 @@ import { useContacts, useCreateBatchItem, useGroups } from "~~/hooks"; import { useBatchTransaction } from "~~/hooks"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; import { formatAddress } from "~~/utils/format"; +import { parseBatchCsv } from "~~/utils/parseBatchCsv"; import { notification } from "~~/utils/scaffold-eth"; -interface BatchContactEntry { +export interface BatchContactEntry { contact: Contact; amount: string; tokenAddress: string; + isSynthetic?: boolean; } interface CreateBatchFromContactsModalProps { isOpen: boolean; onClose: () => void; accountId?: string; + mode?: "manual" | "csv"; + initialBatchItems?: BatchContactEntry[]; [key: string]: any; } @@ -33,16 +37,19 @@ 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 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 +59,6 @@ export default function CreateBatchFromContactsModal({ totalSteps, } = useBatchTransaction({ onSuccess: () => { - notification.success("Batch transaction created!"); handleReset(); onClose(); }, @@ -60,11 +66,21 @@ 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([]); + hasAppliedInitialItems.current = false; }; const handleClose = () => { @@ -108,6 +124,54 @@ export default function CreateBatchFromContactsModal({ setStep(2); }; + // CSV upload handler + const handleCsvUpload = (file: File) => { + 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: formatAddress(entry.address, { start: 6, end: 4 }), + address: entry.address, + accountId: "", + groups: [], + createdAt: "", + updatedAt: "", + } as Contact, + amount: entry.amount, + tokenAddress: entry.tokenAddress, + isSynthetic: true, + })); + + setBatchEntries(entries); + setStep(2); + }; + reader.readAsText(file); + }; + + // 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 +207,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 +218,194 @@ 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 ? ( + + ) : ( + + ))} - {/* 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 --- +function StepUploadCsv({ onUpload }: { onUpload: (file: File) => void }) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleFile = (file: File) => { + if (!file.name.endsWith(".csv")) { + notification.error("Please upload a CSV file"); + return; + } + onUpload(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + return ( +
+
setIsDragging(false)} + onClick={() => fileInputRef.current?.click()} + className={`flex flex-col items-center justify-center gap-3 border-2 border-dashed rounded-xl p-10 cursor-pointer transition-colors ${ + isDragging ? "border-main-pink bg-pink-50" : "border-grey-300 hover:border-grey-400" + }`} + > + +

+ Drag & drop a CSV file here, or browse +

+ { + const file = e.target.files?.[0]; + if (file) handleFile(file); + e.target.value = ""; + }} + /> +
+ +
+

Expected CSV format:

+ + address,amount,token +
+ 0x1234...abcd,1.5,ETH +
+ 0x5678...efgh,50.5,USDC +
+ 0x5678...ef12,4.8,ZEN +
+
+
+ ); +} + +// --- Step 1b: Choose Contact --- function StepChooseContact({ contacts, groups, diff --git a/packages/nextjs/utils/parseBatchCsv.ts b/packages/nextjs/utils/parseBatchCsv.ts new file mode 100644 index 0000000..9f38c4d --- /dev/null +++ b/packages/nextjs/utils/parseBatchCsv.ts @@ -0,0 +1,68 @@ +import { getTokenBySymbol } from "@polypay/shared"; +import { isAddress } from "viem"; + +interface ParsedBatchEntry { + address: string; + amount: string; + tokenAddress: string; +} + +interface ParseBatchCsvResult { + validEntries: ParsedBatchEntry[]; + invalidCount: number; +} + +const HEADER_PATTERN = /^address,amount,token$/i; + +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 }; + } + + let startIndex = 0; + if (HEADER_PATTERN.test(lines[0].trim())) { + 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++; + } + } + + return { validEntries, invalidCount }; +} From f8d61a4433c67f4be13111032f8265a433b25c61 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Mon, 23 Mar 2026 15:56:55 +0700 Subject: [PATCH 2/2] feat: improve batch UX with duplicate button, dropdown redesign, and CSV upload Covers: - Duplicate button moved into expanded batch detail (removed BatchRowMenu) - Contact Book dropdown restyled with custom icons - CSV upload modal redesigned with multi-state file handling - CSV parser: robust header detection + merge duplicate address+token rows --- packages/nextjs/app/contact-book/page.tsx | 12 +- .../Dashboard/TransactionRow/BatchRowMenu.tsx | 84 ------ .../Dashboard/TransactionRow/TxHeader.tsx | 95 +++++-- .../Dashboard/TransactionRow/index.tsx | 5 +- .../modals/CreateBatchFromContactsModal.tsx | 243 ++++++++++++++---- .../public/icons/batch/close-circle.svg | 5 + .../nextjs/public/icons/batch/csv-file.svg | 9 + .../public/icons/batch/document-download.svg | 4 + .../public/icons/batch/export-upload.svg | 4 + .../nextjs/public/icons/batch/user-edit.svg | 4 + .../public/templates/batch-template.csv | 1 + packages/nextjs/utils/parseBatchCsv.ts | 24 +- 12 files changed, 321 insertions(+), 169 deletions(-) delete mode 100644 packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx create mode 100644 packages/nextjs/public/icons/batch/close-circle.svg create mode 100644 packages/nextjs/public/icons/batch/csv-file.svg create mode 100644 packages/nextjs/public/icons/batch/document-download.svg create mode 100644 packages/nextjs/public/icons/batch/export-upload.svg create mode 100644 packages/nextjs/public/icons/batch/user-edit.svg create mode 100644 packages/nextjs/public/templates/batch-template.csv diff --git a/packages/nextjs/app/contact-book/page.tsx b/packages/nextjs/app/contact-book/page.tsx index b12f7e5..9614232 100644 --- a/packages/nextjs/app/contact-book/page.tsx +++ b/packages/nextjs/app/contact-book/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { Contact, ContactGroup } from "@polypay/shared"; -import { ChevronDown, Search, Upload, Users } 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"; @@ -145,25 +145,25 @@ export default function AddressBookPage() { {batchDropdownOpen && ( -
+
diff --git a/packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx b/packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx deleted file mode 100644 index b3effcd..0000000 --- a/packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { useRef, useState } from "react"; -import { Contact, ZERO_ADDRESS, formatTokenAmount, getTokenByAddress } from "@polypay/shared"; -import { Copy, MoreVertical } from "lucide-react"; -import { BatchContactEntry } from "~~/components/modals/CreateBatchFromContactsModal"; -import { modalManager } from "~~/components/modals/ModalLayout"; -import { BatchTransfer } from "~~/hooks"; -import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; -import { useClickOutside } from "~~/hooks/useClickOutside"; -import { useAccountStore } from "~~/services/store"; -import { formatAddress } from "~~/utils/format"; - -interface BatchRowMenuProps { - batchData: BatchTransfer[]; -} - -export function BatchRowMenu({ batchData }: BatchRowMenuProps) { - const [open, setOpen] = useState(false); - const containerRef = useRef(null); - const { chainId } = useNetworkTokens(); - const { currentAccount } = useAccountStore(); - - useClickOutside(containerRef, () => setOpen(false), { isActive: open }); - - const handleDuplicate = () => { - setOpen(false); - - const initialBatchItems: BatchContactEntry[] = batchData.map(transfer => { - const token = getTokenByAddress(transfer.tokenAddress, chainId); - const amount = formatTokenAmount(transfer.amount, token.decimals); - - const contact: Contact = { - id: crypto.randomUUID(), - name: transfer.contactName || formatAddress(transfer.recipient, { start: 6, end: 4 }), - address: transfer.recipient, - accountId: "", - groups: [], - createdAt: "", - updatedAt: "", - } as Contact; - - return { - contact, - amount, - tokenAddress: transfer.tokenAddress || ZERO_ADDRESS, - isSynthetic: true, - }; - }); - - modalManager.openModal?.("createBatchFromContacts", { - accountId: currentAccount?.id, - initialBatchItems, - }); - }; - - return ( -
- - {open && ( -
- -
- )} -
- ); -} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx b/packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx index 2b33661..c2f2e2d 100644 --- a/packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx +++ b/packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx @@ -1,9 +1,15 @@ +"use client"; + import React from "react"; import Image from "next/image"; import { AddressWithContact } from "./AddressWithContact"; import { getExpandedHeaderText } from "./utils"; -import { TxType, getTokenByAddress } from "@polypay/shared"; -import { TransactionRowData, VoteStatus, useNetworkTokens } from "~~/hooks"; +import { TxType, ZERO_ADDRESS, formatTokenAmount, getTokenByAddress } from "@polypay/shared"; +import { Contact } from "@polypay/shared"; +import { BatchContactEntry } from "~~/components/modals/CreateBatchFromContactsModal"; +import { modalManager } from "~~/components/modals/ModalLayout"; +import { BatchTransfer, TransactionRowData, VoteStatus, useNetworkTokens } from "~~/hooks"; +import { useAccountStore } from "~~/services/store"; import { formatAddress, formatAmount } from "~~/utils/format"; interface TxHeaderProps { @@ -14,6 +20,7 @@ interface TxHeaderProps { loading: boolean; initiatorName?: string; initiatorCommitment: string; + batchData?: BatchTransfer[]; } function SignerBadgeList({ signerData }: { signerData: TransactionRowData["signerData"] }) { @@ -56,46 +63,92 @@ export function TxHeader({ loading, initiatorCommitment, initiatorName, + batchData, }: TxHeaderProps) { const headerText = getExpandedHeaderText(tx.type); const shortCommitment = formatAddress(initiatorCommitment, { start: 4, end: 4 }); const { chainId } = useNetworkTokens(); + const { currentAccount } = useAccountStore(); + + const handleDuplicate = () => { + if (!batchData) return; + + const initialBatchItems: BatchContactEntry[] = batchData.map(transfer => { + const token = getTokenByAddress(transfer.tokenAddress, chainId); + const amount = formatTokenAmount(transfer.amount, token.decimals); + + const contact: Contact = { + id: crypto.randomUUID(), + name: transfer.contactName || formatAddress(transfer.recipient, { start: 6, end: 4 }), + address: transfer.recipient, + accountId: "", + groups: [], + createdAt: "", + updatedAt: "", + } as Contact; + + return { + contact, + amount, + tokenAddress: transfer.tokenAddress || ZERO_ADDRESS, + isSynthetic: true, + }; + }); + + modalManager.openModal?.("createBatchFromContacts", { + accountId: currentAccount?.id, + initialBatchItems, + }); + }; const renderHeaderRow = () => (
{tx.type === TxType.BATCH ? ( - {tx.batchData?.length ?? 0} transactions + {tx.batchData?.length ?? 0} Transactions ) : ( {headerText} {initiatorName ? `${initiatorName} (${shortCommitment})` : shortCommitment} )}
- {myVoteStatus === null && ( -
- +
+ {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 0cdfdbf..0632547 100644 --- a/packages/nextjs/components/Dashboard/TransactionRow/index.tsx +++ b/packages/nextjs/components/Dashboard/TransactionRow/index.tsx @@ -2,12 +2,11 @@ import React, { useEffect, useState } from "react"; import { ActionButtons, AwaitingBadge, StatusBadge } from "./Badges"; -import { BatchRowMenu } from "./BatchRowMenu"; import { SignerList } from "./SignerList"; import { TxDetails } from "./TxDetails"; import { TxHeader } from "./TxHeader"; import { getTxTypeLabel } from "./utils"; -import { TxStatus, TxType } from "@polypay/shared"; +import { TxStatus } from "@polypay/shared"; import { ChevronDown, ChevronRight } from "lucide-react"; import { TransactionRowData, useTransactionVote, useWalletCommitments, useWalletThreshold } from "~~/hooks"; import { formatAddress } from "~~/utils/format"; @@ -130,7 +129,6 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) {
e.stopPropagation()}> {renderRightSide()} - {tx.type === TxType.BATCH && tx.batchData && }
@@ -144,6 +142,7 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) { loading={loading} initiatorCommitment={initiatorCommitment} initiatorName={initiatorName} + batchData={tx.batchData} /> >(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); @@ -80,6 +81,7 @@ export default function CreateBatchFromContactsModal({ setSelectedContactIds(new Set()); setSelectedGroupId(null); setBatchEntries([]); + setCsvFile(null); hasAppliedInitialItems.current = false; }; @@ -124,8 +126,9 @@ export default function CreateBatchFromContactsModal({ setStep(2); }; - // CSV upload handler - const handleCsvUpload = (file: File) => { + // CSV continue handler + const handleCsvContinue = () => { + if (!csvFile) return; const reader = new FileReader(); reader.onload = e => { const text = e.target?.result as string; @@ -145,7 +148,7 @@ export default function CreateBatchFromContactsModal({ const entries: BatchContactEntry[] = validEntries.map(entry => ({ contact: { id: crypto.randomUUID(), - name: formatAddress(entry.address, { start: 6, end: 4 }), + name: "", address: entry.address, accountId: "", groups: [], @@ -160,7 +163,7 @@ export default function CreateBatchFromContactsModal({ setBatchEntries(entries); setStep(2); }; - reader.readAsText(file); + reader.readAsText(csvFile); }; // Back button logic @@ -251,7 +254,7 @@ export default function CreateBatchFromContactsModal({
{step === 1 && (isCsvMode ? ( - + setCsvFile(null)} /> ) : (
- {step === 1 && !isCsvMode && ( - - )} + {step === 1 && + (isCsvMode ? ( + + ) : ( + + ))} {step === 2 && ( +
+ )} + + {/* File info card - loaded state */} + {uploadState === "loaded" && file && ( +
+ CSV +
+

{file.name}

+

{formatFileSize(file.size)}

+
+ +
+ )} + + {/* Download template link */} +
+ Download + + {templateDownloading && ( +
+ {templateProgress}% +
+
+ )}
); @@ -535,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 index 9f38c4d..9b71d98 100644 --- a/packages/nextjs/utils/parseBatchCsv.ts +++ b/packages/nextjs/utils/parseBatchCsv.ts @@ -12,8 +12,6 @@ interface ParseBatchCsvResult { invalidCount: number; } -const HEADER_PATTERN = /^address,amount,token$/i; - export function parseBatchCsv(csvText: string, chainId: number): ParseBatchCsvResult { // Strip BOM const cleaned = csvText.replace(/^\uFEFF/, ""); @@ -23,8 +21,10 @@ export function parseBatchCsv(csvText: string, chainId: number): ParseBatchCsvRe 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; - if (HEADER_PATTERN.test(lines[0].trim())) { + const firstField = lines[0].split(",")[0].trim(); + if (!isAddress(firstField)) { startIndex = 1; } @@ -64,5 +64,21 @@ export function parseBatchCsv(csvText: string, chainId: number): ParseBatchCsvRe } } - return { validEntries, 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 }; }