Skip to content

Commit 26d458c

Browse files
authored
feat: add CSV upload and duplicate batch flows for batch creation (#219)
* feat: add CSV upload and duplicate batch flows for batch creation * 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
1 parent cc77d2a commit 26d458c

11 files changed

Lines changed: 607 additions & 132 deletions

File tree

packages/nextjs/app/contact-book/page.tsx

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { useEffect, useMemo, useRef, useState } from "react";
44
import Image from "next/image";
55
import { useRouter } from "next/navigation";
66
import { Contact, ContactGroup } from "@polypay/shared";
7-
import { Search } from "lucide-react";
7+
import { ChevronDown, Search } from "lucide-react";
88
import { ContactDetailDrawer } from "~~/components/contact-book/ContactDetailDrawer";
99
import { ContactList } from "~~/components/contact-book/ContactList";
1010
import { EditContact } from "~~/components/contact-book/Editcontact";
1111
import { modalManager } from "~~/components/modals/ModalLayout";
1212
import { useContacts, useGroups } from "~~/hooks";
13+
import { useClickOutside } from "~~/hooks/useClickOutside";
1314
import { useAccountStore } from "~~/services/store";
1415
import { formatAddress } from "~~/utils/format";
1516

@@ -23,7 +24,10 @@ export default function AddressBookPage() {
2324
const [searchTerm, setSearchTerm] = useState("");
2425
const [editing, setEditing] = useState(false);
2526
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
27+
const [batchDropdownOpen, setBatchDropdownOpen] = useState(false);
2628
const searchInputRef = useRef<HTMLInputElement>(null);
29+
const batchDropdownRef = useRef<HTMLDivElement>(null);
30+
useClickOutside(batchDropdownRef, () => setBatchDropdownOpen(false), { isActive: batchDropdownOpen });
2731

2832
const { data: groups = [], refetch: refetchGroups } = useGroups(accountId);
2933
const {
@@ -131,17 +135,40 @@ export default function AddressBookPage() {
131135
<Image src="/contact-book/new-group.svg" alt="New group" width={20} height={20} />
132136
New group
133137
</button>
134-
<button
135-
className="bg-main-black text-white rounded-lg text-sm font-medium transition-colors cursor-pointer h-12 px-3 flex items-center gap-1"
136-
onClick={() =>
137-
modalManager.openModal?.("createBatchFromContacts", {
138-
accountId,
139-
})
140-
}
141-
>
142-
<Image src="/contact-book/create-batch.svg" alt="Create batch" width={20} height={20} />
143-
Create batch
144-
</button>
138+
<div ref={batchDropdownRef} className="relative">
139+
<button
140+
className="bg-main-black text-white rounded-lg text-sm font-medium transition-colors cursor-pointer h-12 px-3 flex items-center gap-1"
141+
onClick={() => setBatchDropdownOpen(prev => !prev)}
142+
>
143+
<Image src="/contact-book/create-batch.svg" alt="Create batch" width={20} height={20} />
144+
Create batch
145+
<ChevronDown size={16} />
146+
</button>
147+
{batchDropdownOpen && (
148+
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg border border-grey-200 shadow-[0px_0px_11px_0px_rgba(0,0,0,0.1)] z-50 min-w-[160px] overflow-hidden">
149+
<button
150+
className="flex items-center gap-[5px] p-3 text-sm font-medium text-main-black hover:bg-grey-50 w-full cursor-pointer border-b border-b-grey-200"
151+
onClick={() => {
152+
setBatchDropdownOpen(false);
153+
modalManager.openModal?.("createBatchFromContacts", { accountId });
154+
}}
155+
>
156+
<Image src="/icons/batch/user-edit.svg" alt="Manual" width={16} height={16} />
157+
Manual
158+
</button>
159+
<button
160+
className="flex items-center gap-[5px] p-3 text-sm font-medium text-main-black hover:bg-grey-50 w-full cursor-pointer"
161+
onClick={() => {
162+
setBatchDropdownOpen(false);
163+
modalManager.openModal?.("createBatchFromContacts", { accountId, mode: "csv" });
164+
}}
165+
>
166+
<Image src="/icons/batch/export-upload.svg" alt="Upload CSV" width={16} height={16} />
167+
Upload CSV
168+
</button>
169+
</div>
170+
)}
171+
</div>
145172
<button
146173
className="bg-main-violet text-white rounded-lg text-sm font-medium transition-colors cursor-pointer h-12 px-3 flex items-center gap-1"
147174
onClick={() =>

packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
"use client";
2+
13
import React from "react";
24
import Image from "next/image";
35
import { AddressWithContact } from "./AddressWithContact";
46
import { getExpandedHeaderText } from "./utils";
5-
import { TxType, getTokenByAddress } from "@polypay/shared";
6-
import { TransactionRowData, VoteStatus, useNetworkTokens } from "~~/hooks";
7+
import { TxType, ZERO_ADDRESS, formatTokenAmount, getTokenByAddress } from "@polypay/shared";
8+
import { Contact } from "@polypay/shared";
9+
import { BatchContactEntry } from "~~/components/modals/CreateBatchFromContactsModal";
10+
import { modalManager } from "~~/components/modals/ModalLayout";
11+
import { BatchTransfer, TransactionRowData, VoteStatus, useNetworkTokens } from "~~/hooks";
12+
import { useAccountStore } from "~~/services/store";
713
import { formatAddress, formatAmount } from "~~/utils/format";
814

915
interface TxHeaderProps {
@@ -14,6 +20,7 @@ interface TxHeaderProps {
1420
loading: boolean;
1521
initiatorName?: string;
1622
initiatorCommitment: string;
23+
batchData?: BatchTransfer[];
1724
}
1825

1926
function SignerBadgeList({ signerData }: { signerData: TransactionRowData["signerData"] }) {
@@ -56,46 +63,92 @@ export function TxHeader({
5663
loading,
5764
initiatorCommitment,
5865
initiatorName,
66+
batchData,
5967
}: TxHeaderProps) {
6068
const headerText = getExpandedHeaderText(tx.type);
6169
const shortCommitment = formatAddress(initiatorCommitment, { start: 4, end: 4 });
6270
const { chainId } = useNetworkTokens();
71+
const { currentAccount } = useAccountStore();
72+
73+
const handleDuplicate = () => {
74+
if (!batchData) return;
75+
76+
const initialBatchItems: BatchContactEntry[] = batchData.map(transfer => {
77+
const token = getTokenByAddress(transfer.tokenAddress, chainId);
78+
const amount = formatTokenAmount(transfer.amount, token.decimals);
79+
80+
const contact: Contact = {
81+
id: crypto.randomUUID(),
82+
name: transfer.contactName || formatAddress(transfer.recipient, { start: 6, end: 4 }),
83+
address: transfer.recipient,
84+
accountId: "",
85+
groups: [],
86+
createdAt: "",
87+
updatedAt: "",
88+
} as Contact;
89+
90+
return {
91+
contact,
92+
amount,
93+
tokenAddress: transfer.tokenAddress || ZERO_ADDRESS,
94+
isSynthetic: true,
95+
};
96+
});
97+
98+
modalManager.openModal?.("createBatchFromContacts", {
99+
accountId: currentAccount?.id,
100+
initialBatchItems,
101+
});
102+
};
63103

64104
const renderHeaderRow = () => (
65105
<div className="flex items-center justify-between mb-4">
66106
<div className="text-lg font-semibold">
67107
{tx.type === TxType.BATCH ? (
68-
<span>{tx.batchData?.length ?? 0} transactions</span>
108+
<span>{tx.batchData?.length ?? 0} Transactions</span>
69109
) : (
70110
<span>
71111
{headerText} {initiatorName ? `${initiatorName} (${shortCommitment})` : shortCommitment}
72112
</span>
73113
)}
74114
</div>
75-
{myVoteStatus === null && (
76-
<div className="flex items-center gap-2">
77-
<button
78-
onClick={e => {
79-
e.stopPropagation();
80-
onDeny();
81-
}}
82-
disabled={loading}
83-
className="px-6 py-2 text-sm font-medium text-main-black bg-white rounded-full hover:bg-gray-100 transition-colors cursor-pointer disabled:opacity-50"
84-
>
85-
Deny
86-
</button>
115+
<div className="flex items-center gap-2">
116+
{tx.type === TxType.BATCH && batchData && (
87117
<button
88118
onClick={e => {
89119
e.stopPropagation();
90-
onApprove();
120+
handleDuplicate();
91121
}}
92-
disabled={loading}
93-
className="px-6 py-2 text-sm font-medium text-main-black bg-pink-350 rounded-full hover:bg-pink-450 transition-colors cursor-pointer disabled:opacity-50"
122+
className="bg-grey-100 rounded-lg px-6 h-7 text-sm font-medium text-main-black cursor-pointer hover:bg-grey-200 transition-colors"
94123
>
95-
{loading ? "Processing..." : "Approve"}
124+
Duplicate
96125
</button>
97-
</div>
98-
)}
126+
)}
127+
{myVoteStatus === null && (
128+
<>
129+
<button
130+
onClick={e => {
131+
e.stopPropagation();
132+
onDeny();
133+
}}
134+
disabled={loading}
135+
className="px-6 py-2 text-sm font-medium text-main-black bg-white rounded-full hover:bg-gray-100 transition-colors cursor-pointer disabled:opacity-50"
136+
>
137+
Deny
138+
</button>
139+
<button
140+
onClick={e => {
141+
e.stopPropagation();
142+
onApprove();
143+
}}
144+
disabled={loading}
145+
className="px-6 py-2 text-sm font-medium text-main-black bg-pink-350 rounded-full hover:bg-pink-450 transition-colors cursor-pointer disabled:opacity-50"
146+
>
147+
{loading ? "Processing..." : "Approve"}
148+
</button>
149+
</>
150+
)}
151+
</div>
99152
</div>
100153
);
101154

packages/nextjs/components/Dashboard/TransactionRow/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) {
127127
<span className="text-sm font-medium text-grey-500 tracking-tight">{getTxTypeLabel(tx.type)}</span>
128128
{!expanded && <TxDetails tx={tx} />}
129129
</div>
130-
<div onClick={e => e.stopPropagation()}>{renderRightSide()}</div>
130+
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
131+
{renderRightSide()}
132+
</div>
131133
</div>
132134

133135
{expanded && (
@@ -140,6 +142,7 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) {
140142
loading={loading}
141143
initiatorCommitment={initiatorCommitment}
142144
initiatorName={initiatorName}
145+
batchData={tx.batchData}
143146
/>
144147
<SignerList
145148
members={tx.members}

0 commit comments

Comments
 (0)