diff --git a/packages/backend/src/admin/admin.service.ts b/packages/backend/src/admin/admin.service.ts index d6f0784..ac132e5 100644 --- a/packages/backend/src/admin/admin.service.ts +++ b/packages/backend/src/admin/admin.service.ts @@ -6,12 +6,15 @@ import { AnalyticsReportDto } from './dto/analytics-report.dto'; import { EXPLORER_URLS } from '@/common/constants/campaign'; import { TxStatus, VoteType } from '@polypay/shared'; +const BASE_CHAIN_IDS = [8453, 84532]; + interface AnalyticsRecord { timestamp: Date; action: string; userAddress: string; multisigWallet: string | null; txHash: string | null; + chainId?: number | null; } @Injectable() @@ -20,6 +23,8 @@ export class AdminService { ZKVERIFY_EXPLORER: string; HORIZEN_EXPLORER_ADDRESS: string; HORIZEN_EXPLORER_TX: string; + BASE_EXPLORER_ADDRESS: string; + BASE_EXPLORER_TX: string; }; constructor( @@ -32,10 +37,7 @@ export class AdminService { EXPLORER_URLS.mainnet; } - /** - * Map TxType to Action name - */ - private mapTxTypeToAction(txType: TxType): string { + private mapTxTypeSuffix(txType: TxType): string { switch (txType) { case TxType.TRANSFER: return 'TRANSFER'; @@ -52,25 +54,50 @@ export class AdminService { } } - /** - * Get blockchain based on action - */ - private getBlockchain(action: string): string { - switch (action) { - case 'EXECUTE': - case 'CREATE_ACCOUNT': - case 'CLAIM': - return 'Horizen'; - case 'DENY': - return ''; - default: - return 'zkVerify'; + private mapTxTypeToAction(txType: TxType): string { + return `PROPOSE_${this.mapTxTypeSuffix(txType)}`; + } + + private mapTxTypeToApproveAction(txType: TxType): string { + return `APPROVE_${this.mapTxTypeSuffix(txType)}`; + } + + private mapTxTypeToDenyAction(txType: TxType): string { + return `DENY_${this.mapTxTypeSuffix(txType)}`; + } + + private mapTxTypeToExecuteAction(txType: TxType): string { + return `EXECUTE_${this.mapTxTypeSuffix(txType)}`; + } + + private getBlockchain(action: string, chainId?: number | null): string { + if ( + action.startsWith('PROPOSE_') || + action.startsWith('APPROVE_') || + action === 'LOGIN' + ) { + return 'zkVerify'; + } + + if (action.startsWith('DENY_')) { + return ''; + } + + if (action === 'CLAIM') { + return 'Horizen'; } + + // EXECUTE_* and CREATE_ACCOUNT: determine from chainId + if (chainId === 8453 || chainId === 84532) { + return 'Base'; + } + if (chainId === 2651420 || chainId === 26514) { + return 'Horizen'; + } + + return 'Horizen'; } - /** - * Build commitment → walletAddress map from loginHistory (batch query) - */ private async buildCommitmentToAddressMap( commitments: string[], ): Promise> { @@ -101,6 +128,14 @@ export class AdminService { } const hasDateFilter = Object.keys(dateFilter).length > 0; + // Base chain filter + const excludeBaseFilter = !dto?.includeBase + ? { chainId: { notIn: BASE_CHAIN_IDS } } + : {}; + const excludeBaseAccountFilter = !dto?.includeBase + ? { account: { chainId: { notIn: BASE_CHAIN_IDS } } } + : {}; + // Build included tx types based on params const includedTxTypes: TxType[] = [TxType.TRANSFER, TxType.BATCH]; if (dto?.includeSignerOps) { @@ -130,16 +165,21 @@ export class AdminService { } // 2. CREATE_ACCOUNT records - const accounts = await this.prisma.account.findMany({ - where: hasDateFilter ? { createdAt: dateFilter } : undefined, - include: { - signers: { - where: { isCreator: true }, - include: { user: true }, - }, - }, - orderBy: { createdAt: 'asc' }, - }); + const accounts = dto?.includeCreateAccount + ? await this.prisma.account.findMany({ + where: { + ...(hasDateFilter ? { createdAt: dateFilter } : {}), + ...excludeBaseFilter, + }, + include: { + signers: { + where: { isCreator: true }, + include: { user: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + : []; // Batch load wallet addresses for account creators const creatorCommitments = accounts @@ -150,10 +190,13 @@ export class AdminService { const approveVotes = await this.prisma.vote.findMany({ where: { voteType: VoteType.APPROVE, - transaction: { type: { in: includedTxTypes } }, + transaction: { + type: { in: includedTxTypes }, + ...excludeBaseAccountFilter, + }, ...(hasDateFilter ? { createdAt: dateFilter } : {}), }, - include: { transaction: true }, + include: { transaction: { include: { account: true } } }, orderBy: { createdAt: 'asc' }, }); @@ -162,10 +205,13 @@ export class AdminService { ? await this.prisma.vote.findMany({ where: { voteType: VoteType.DENY, - transaction: { type: { in: includedTxTypes } }, + transaction: { + type: { in: includedTxTypes }, + ...excludeBaseAccountFilter, + }, ...(hasDateFilter ? { createdAt: dateFilter } : {}), }, - include: { transaction: true }, + include: { transaction: { include: { account: true } } }, orderBy: { createdAt: 'asc' }, }) : []; @@ -175,8 +221,10 @@ export class AdminService { where: { status: TxStatus.EXECUTED, type: { in: includedTxTypes }, + ...excludeBaseAccountFilter, ...(hasDateFilter ? { executedAt: dateFilter } : {}), }, + include: { account: true }, orderBy: { executedAt: 'asc' }, }); @@ -200,6 +248,7 @@ export class AdminService { userAddress: addressMap.get(creator.user.commitment) || 'UNKNOWN', multisigWallet: account.address, txHash: account.address, + chainId: account.chainId, }); } @@ -220,7 +269,9 @@ export class AdminService { for (let i = 0; i < votes.length; i++) { const vote = votes[i]; const action = - i === 0 ? this.mapTxTypeToAction(vote.transaction.type) : 'APPROVE'; + i === 0 + ? this.mapTxTypeToAction(vote.transaction.type) + : this.mapTxTypeToApproveAction(vote.transaction.type); records.push({ timestamp: vote.createdAt, @@ -228,6 +279,7 @@ export class AdminService { userAddress: addressMap.get(vote.voterCommitment) || 'UNKNOWN', multisigWallet: vote.transaction.accountAddress, txHash: vote.zkVerifyTxHash || 'PENDING', + chainId: vote.transaction.account.chainId, }); } } @@ -236,10 +288,11 @@ export class AdminService { for (const vote of denyVotes) { records.push({ timestamp: vote.createdAt, - action: 'DENY', + action: this.mapTxTypeToDenyAction(vote.transaction.type), userAddress: addressMap.get(vote.voterCommitment) || 'UNKNOWN', multisigWallet: vote.transaction.accountAddress, txHash: null, + chainId: vote.transaction.account.chainId, }); } @@ -265,37 +318,86 @@ export class AdminService { for (const tx of executedTxs) { records.push({ timestamp: tx.executedAt || tx.updatedAt, - action: 'EXECUTE', + action: this.mapTxTypeToExecuteAction(tx.type), userAddress: addressMap.get(tx.createdBy) || 'UNKNOWN', multisigWallet: tx.accountAddress, txHash: tx.txHash || 'PENDING', + chainId: tx.account.chainId, }); } // Sort all records by timestamp records.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - // Generate CSV // Count totals by blockchain const totalZkVerify = records.filter( - (r) => this.getBlockchain(r.action) === 'zkVerify', + (r) => this.getBlockchain(r.action, r.chainId) === 'zkVerify', ).length; const totalHorizen = records.filter( - (r) => this.getBlockchain(r.action) === 'Horizen', + (r) => this.getBlockchain(r.action, r.chainId) === 'Horizen', + ).length; + const totalBase = records.filter( + (r) => this.getBlockchain(r.action, r.chainId) === 'Base', ).length; // Generate CSV - return this.generateCSV(records, totalZkVerify, totalHorizen); + return this.generateCSV( + records, + totalZkVerify, + totalHorizen, + totalBase, + dto?.includeBase, + ); + } + + private getTxHashLink( + action: string, + txHash: string, + chainId?: number | null, + ): string { + const isBase = chainId === 8453 || chainId === 84532; + + const explorerMap = { + zkVerify: this.explorerConfig.ZKVERIFY_EXPLORER, + chainTx: isBase + ? this.explorerConfig.BASE_EXPLORER_TX + : this.explorerConfig.HORIZEN_EXPLORER_TX, + chainAddress: isBase + ? this.explorerConfig.BASE_EXPLORER_ADDRESS + : this.explorerConfig.HORIZEN_EXPLORER_ADDRESS, + }; + + let explorerKey: keyof typeof explorerMap | null = null; + + if ( + action.startsWith('PROPOSE_') || + action.startsWith('APPROVE_') || + action === 'LOGIN' + ) { + explorerKey = 'zkVerify'; + } else if (action === 'CREATE_ACCOUNT') { + explorerKey = 'chainAddress'; + } else if (action.startsWith('EXECUTE_') || action === 'CLAIM') { + explorerKey = 'chainTx'; + } + + if (!explorerKey) return ''; + + return `${explorerMap[explorerKey]}/${txHash}`; } private generateCSV( records: AnalyticsRecord[], totalZkVerify: number, totalHorizen: number, + totalBase: number, + includeBase?: boolean, ): string { const totalsHeader = [ `Total zkVerify,${totalZkVerify}`, `Total Horizen,${totalHorizen}`, + `Total (zkVerify + Horizen),${totalZkVerify + totalHorizen}`, + ...(includeBase ? [`Total Base,${totalBase}`] : []), '', // empty line ]; @@ -305,7 +407,7 @@ export class AdminService { const rows = records.map((record) => { const timestamp = record.timestamp.toISOString(); const action = record.action; - const blockchain = this.getBlockchain(record.action); + const blockchain = this.getBlockchain(record.action, record.chainId); const userAddress = record.userAddress ? `${this.explorerConfig.HORIZEN_EXPLORER_ADDRESS}/${record.userAddress}` : ''; @@ -315,23 +417,11 @@ export class AdminService { let txHash = ''; if (record.txHash && record.txHash !== 'PENDING') { - if ( - record.action === 'LOGIN' || - record.action === 'APPROVE' || - record.action === 'TRANSFER' || - record.action === 'BATCH_TRANSFER' || - record.action === 'ADD_SIGNER' || - record.action === 'REMOVE_SIGNER' || - record.action === 'UPDATE_THRESHOLD' - ) { - txHash = `${this.explorerConfig.ZKVERIFY_EXPLORER}/${record.txHash}`; - } else if (record.action === 'CREATE_ACCOUNT') { - txHash = `${this.explorerConfig.HORIZEN_EXPLORER_ADDRESS}/${record.txHash}`; - } else if (record.action === 'EXECUTE') { - txHash = `${this.explorerConfig.HORIZEN_EXPLORER_TX}/${record.txHash}`; - } else if (record.action === 'CLAIM') { - txHash = `${this.explorerConfig.HORIZEN_EXPLORER_TX}/${record.txHash}`; - } + txHash = this.getTxHashLink( + record.action, + record.txHash, + record.chainId, + ); } else if (record.txHash === 'PENDING') { txHash = 'PENDING'; } diff --git a/packages/backend/src/admin/dto/analytics-report.dto.ts b/packages/backend/src/admin/dto/analytics-report.dto.ts index 21cd90e..76a3fcb 100644 --- a/packages/backend/src/admin/dto/analytics-report.dto.ts +++ b/packages/backend/src/admin/dto/analytics-report.dto.ts @@ -39,7 +39,7 @@ export class AnalyticsReportDto { @ApiPropertyOptional({ description: 'Include CLAIM records', - default: true, + default: false, }) @IsOptional() @IsBoolean() @@ -54,4 +54,22 @@ export class AnalyticsReportDto { @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) includeSignerOps?: boolean; + + @ApiPropertyOptional({ + description: 'Include CREATE_ACCOUNT records', + default: false, + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + includeCreateAccount?: boolean; + + @ApiPropertyOptional({ + description: 'Include Base chain (chainId 8453, 84532) records', + default: false, + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + includeBase?: boolean; } diff --git a/packages/backend/src/common/constants/campaign.ts b/packages/backend/src/common/constants/campaign.ts index bec72fe..6a89bdb 100644 --- a/packages/backend/src/common/constants/campaign.ts +++ b/packages/backend/src/common/constants/campaign.ts @@ -27,11 +27,15 @@ export const EXPLORER_URLS = { ZKVERIFY_EXPLORER: 'https://zkverify.subscan.io/tx', HORIZEN_EXPLORER_ADDRESS: 'https://horizen.calderaexplorer.xyz/address', HORIZEN_EXPLORER_TX: 'https://horizen.calderaexplorer.xyz/tx', + BASE_EXPLORER_ADDRESS: 'https://basescan.org/address', + BASE_EXPLORER_TX: 'https://basescan.org/tx', }, testnet: { ZKVERIFY_EXPLORER: 'https://zkverify-testnet.subscan.io/tx', HORIZEN_EXPLORER_ADDRESS: 'https://horizen-testnet.explorer.caldera.xyz/address', HORIZEN_EXPLORER_TX: 'https://horizen-testnet.explorer.caldera.xyz/tx', + BASE_EXPLORER_ADDRESS: 'https://sepolia.basescan.org/address', + BASE_EXPLORER_TX: 'https://sepolia.basescan.org/tx', }, } as const; 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 }; +}