Skip to content

Commit 2114e5f

Browse files
committed
Add text input option to airdrop setup (#7238)
- Add manual text input alternative to CSV upload for airdrop addresses - Support multiple input formats: space, comma, equals, and tab separated - Parse text input and convert to CSV format for existing validation system - Reuse all existing functionality: ENS resolution, address validation, duplicate removal - Maintain same validation flow and error handling as CSV upload - Change button text from 'Upload CSV' to 'Set up Airdrop' for clarity ![image](https://github.com/user-attachments/assets/e43afdd8-eed8-491a-ac93-9f02eb6ee703) ![image](https://github.com/user-attachments/assets/42522fa1-3886-4d68-a113-079d58b3da96) <!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR enhances the CSV upload functionality in the `TokenAirdropSection` component. It introduces a new method for processing CSV data, allows manual entry of addresses and amounts, and updates UI labels to reflect the new functionality. ### Detailed summary - Added `processData` function in `useCsvUpload` for processing parsed CSV data. - Updated `TokenAirdropSection` UI labels from "CSV File Uploaded" to "Airdrop List Set". - Changed button text from "View CSV" to "View List". - Introduced `parseTextInput` function for manual input parsing. - Added text input for entering addresses and amounts. - Implemented handling for text input submission. - Updated the layout to separate CSV upload and manual entry sections. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added the ability to manually enter airdrop addresses and amounts using a text area, supporting multiple input formats. - Users can now choose between uploading a CSV file or entering addresses and amounts directly. - **UI Updates** - Updated labels and button texts to reflect support for both CSV and manual list input. - Improved layout with a visual divider and clear instructions for manual entry. - Enhanced reset and validation behaviors for both input methods. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent ca08417 commit 2114e5f

File tree

2 files changed

+196
-77
lines changed

2 files changed

+196
-77
lines changed

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-airdrop.tsx

Lines changed: 181 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
TableHeader,
2424
TableRow,
2525
} from "@/components/ui/table";
26+
import { Textarea } from "@/components/ui/textarea";
2627
import { cn } from "@/lib/utils";
2728
import { useCsvUpload } from "hooks/useCsvUpload";
2829
import {
@@ -92,7 +93,7 @@ export function TokenAirdropSection(props: {
9293
<div className="flex w-full flex-col gap-4 rounded-lg border bg-background p-4 md:flex-row lg:items-center lg:justify-between">
9394
{/* left */}
9495
<div>
95-
<h3 className="font-medium text-sm">CSV File Uploaded</h3>
96+
<h3 className="font-medium text-sm">Airdrop List Set</h3>
9697
<p className="text-muted-foreground text-sm">
9798
<span className="font-semibold">
9899
{airdropAddresses.length}
@@ -109,14 +110,14 @@ export function TokenAirdropSection(props: {
109110
<SheetTrigger asChild>
110111
<Button size="sm" variant="outline">
111112
<FileTextIcon className="mr-2 size-4" />
112-
View CSV
113+
View List
113114
</Button>
114115
</SheetTrigger>
115116

116117
<SheetContent className="flex h-dvh w-full flex-col gap-0 overflow-hidden lg:max-w-2xl">
117118
<SheetHeader className="mb-3">
118119
<SheetTitle className="text-left">
119-
Airdrop CSV
120+
Airdrop List
120121
</SheetTitle>
121122
</SheetHeader>
122123
<AirdropTable
@@ -152,11 +153,11 @@ export function TokenAirdropSection(props: {
152153
<SheetContent className="flex h-dvh w-full flex-col gap-0 overflow-hidden lg:max-w-2xl">
153154
<SheetHeader className="mb-3">
154155
<SheetTitle className="text-left font-semibold text-lg">
155-
Airdrop CSV File
156+
Set up Airdrop
156157
</SheetTitle>
157158
<SheetDescription>
158-
Upload a CSV file to airdrop tokens to a list of
159-
addresses
159+
Upload a CSV file or enter comma-separated addresses and
160+
amounts to airdrop tokens
160161
</SheetDescription>
161162
</SheetHeader>
162163
<AirdropUpload
@@ -176,7 +177,7 @@ export function TokenAirdropSection(props: {
176177
className="min-w-44 gap-2 bg-background"
177178
>
178179
<ArrowUpFromLineIcon className="size-4 text-muted-foreground" />
179-
Upload CSV
180+
Set up Airdrop
180181
</Button>
181182
</div>
182183
)}
@@ -193,21 +194,52 @@ type AirdropUploadProps = {
193194
client: ThirdwebClient;
194195
};
195196

196-
// CSV parser for airdrop data
197-
const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => {
198-
return items
199-
.map(({ address, quantity }) => ({
200-
address: (address || "").trim(),
201-
quantity: (quantity || "1").trim(),
202-
}))
203-
.filter(({ address }) => address !== "");
197+
// Parse text input and convert to CSV-like format
198+
const parseTextInput = (text: string): AirdropAddressInput[] => {
199+
const lines = text
200+
.split("\n")
201+
.map((line) => line.trim())
202+
.filter((line) => line !== "");
203+
const result: AirdropAddressInput[] = [];
204+
205+
for (const line of lines) {
206+
let parts: string[] = [];
207+
208+
if (line.includes("=")) {
209+
parts = line.split("=");
210+
} else if (line.includes(",")) {
211+
parts = line.split(",");
212+
} else if (line.includes("\t")) {
213+
parts = line.split("\t");
214+
} else {
215+
parts = line.split(/\s+/);
216+
}
217+
218+
parts = parts.map((part) => part.trim()).filter((part) => part !== "");
219+
220+
if (parts.length >= 1) {
221+
const address = parts[0];
222+
const quantity = parts[1] || "1";
223+
224+
if (address) {
225+
result.push({
226+
address: address.trim(),
227+
quantity: quantity.trim(),
228+
});
229+
}
230+
}
231+
}
232+
233+
return result;
204234
};
205235

206236
const AirdropUpload: React.FC<AirdropUploadProps> = ({
207237
setAirdrop,
208238
onClose,
209239
client,
210240
}) => {
241+
const [textInput, setTextInput] = useState("");
242+
211243
const {
212244
normalizeQuery,
213245
getInputProps,
@@ -216,11 +248,30 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
216248
noCsv,
217249
reset,
218250
removeInvalid,
219-
} = useCsvUpload<AirdropAddressInput>({ csvParser, client });
251+
processData,
252+
} = useCsvUpload<AirdropAddressInput>({
253+
csvParser: (items: AirdropAddressInput[]) => {
254+
return items
255+
.map(({ address, quantity }) => ({
256+
address: (address || "").trim(),
257+
quantity: (quantity || "1").trim(),
258+
}))
259+
.filter(({ address }) => address !== "");
260+
},
261+
client,
262+
});
220263

221264
const normalizeData = normalizeQuery.data;
222265

223-
if (!normalizeData) {
266+
// Handle text input - directly process the parsed data
267+
const handleTextSubmit = () => {
268+
if (!textInput.trim()) return;
269+
270+
const parsedData = parseTextInput(textInput);
271+
processData(parsedData);
272+
};
273+
274+
if (!normalizeData && rawData.length > 0) {
224275
return (
225276
<div className="flex h-[300px] w-full grow items-center justify-center rounded-lg border border-border">
226277
<Spinner className="size-10" />
@@ -229,6 +280,8 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
229280
}
230281

231282
const handleContinue = () => {
283+
if (!normalizeData) return;
284+
232285
setAirdrop(
233286
normalizeData.result.map((o) => ({
234287
address: o.resolvedAddress || o.address,
@@ -239,9 +292,16 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
239292
onClose();
240293
};
241294

295+
const handleReset = () => {
296+
reset();
297+
setTextInput("");
298+
};
299+
242300
return (
243301
<div className="flex w-full grow flex-col gap-6 overflow-hidden">
244-
{normalizeData.result.length && rawData.length > 0 ? (
302+
{normalizeData &&
303+
normalizeData.result.length > 0 &&
304+
rawData.length > 0 ? (
245305
<div className="flex grow flex-col overflow-hidden outline">
246306
{normalizeQuery.data.invalidFound && (
247307
<p className="mb-3 text-red-500 text-sm">
@@ -253,19 +313,12 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
253313
className="rounded-b-none"
254314
/>
255315
<div className="flex justify-between gap-3 rounded-b-lg border border-t-0 bg-card p-6">
256-
<Button
257-
variant="outline"
258-
disabled={rawData.length === 0}
259-
onClick={() => {
260-
reset();
261-
}}
262-
>
316+
<Button variant="outline" onClick={handleReset}>
263317
<RotateCcwIcon className="mr-2 size-4" />
264318
Reset
265319
</Button>
266320
{normalizeQuery.data.invalidFound ? (
267321
<Button
268-
disabled={rawData.length === 0}
269322
onClick={() => {
270323
removeInvalid();
271324
}}
@@ -274,69 +327,120 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
274327
Remove invalid addresses
275328
</Button>
276329
) : (
277-
<Button onClick={handleContinue} disabled={rawData.length === 0}>
330+
<Button onClick={handleContinue}>
278331
Continue <ArrowRightIcon className="ml-2 size-4" />
279332
</Button>
280333
)}
281334
</div>
282335
</div>
283336
) : (
284-
<div>
285-
<div className="relative w-full">
286-
<div
287-
className={cn(
288-
"flex h-[300px] cursor-pointer items-center justify-center rounded-md border border-dashed bg-card hover:border-active-border",
289-
noCsv &&
290-
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
291-
)}
292-
{...getRootProps()}
293-
>
294-
<input {...getInputProps()} accept=".csv" />
295-
<div className="flex flex-col items-center justify-center gap-3">
296-
{!noCsv && (
297-
<div className="flex flex-col items-center">
298-
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
299-
<UploadIcon className="size-5" />
300-
</div>
301-
<h2 className="mb-0.5 text-center font-medium text-lg">
302-
Upload CSV File
303-
</h2>
304-
<p className="text-center font-medium text-muted-foreground text-sm">
305-
Drag and drop your file or click here to upload
306-
</p>
307-
</div>
337+
<div className="flex flex-col gap-6">
338+
{/* CSV Upload Section - First */}
339+
<div className="space-y-4">
340+
<CSVFormatDetails />
341+
342+
<div className="relative w-full">
343+
<div
344+
className={cn(
345+
"flex h-[180px] cursor-pointer items-center justify-center rounded-md border border-dashed bg-card hover:border-active-border",
346+
noCsv &&
347+
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
308348
)}
349+
{...getRootProps()}
350+
>
351+
<input {...getInputProps()} accept=".csv" />
352+
<div className="flex flex-col items-center justify-center gap-3">
353+
{!noCsv && (
354+
<div className="flex flex-col items-center">
355+
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
356+
<UploadIcon className="size-5" />
357+
</div>
358+
<h2 className="mb-0.5 text-center font-medium text-lg">
359+
Upload CSV File
360+
</h2>
361+
<p className="text-center font-medium text-muted-foreground text-sm">
362+
Drag and drop your file or click here to upload
363+
</p>
364+
</div>
365+
)}
366+
367+
{noCsv && (
368+
<div className="flex flex-col items-center">
369+
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
370+
<XIcon className="size-5" />
371+
</div>
372+
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
373+
Invalid CSV
374+
</h2>
375+
<p className="text-balance text-center text-sm">
376+
Your CSV does not contain the "address" & "quantity"
377+
columns
378+
</p>
309379

310-
{noCsv && (
311-
<div className="flex flex-col items-center">
312-
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
313-
<XIcon className="size-5" />
380+
<Button
381+
className="relative z-50 mt-4"
382+
size="sm"
383+
onClick={(e) => {
384+
e.stopPropagation();
385+
reset();
386+
}}
387+
>
388+
Remove Invalid CSV
389+
</Button>
314390
</div>
315-
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
316-
Invalid CSV
317-
</h2>
318-
<p className="text-balance text-center text-sm">
319-
Your CSV does not contain the "address" & "quantity"
320-
columns
321-
</p>
322-
323-
<Button
324-
className="relative z-50 mt-4"
325-
size="sm"
326-
onClick={(e) => {
327-
e.stopPropagation();
328-
reset();
329-
}}
330-
>
331-
Remove Invalid CSV
332-
</Button>
333-
</div>
334-
)}
391+
)}
392+
</div>
393+
</div>
394+
</div>
395+
</div>
396+
397+
{/* Divider */}
398+
<div className="relative">
399+
<div className="absolute inset-0 flex items-center">
400+
<span className="w-full border-t" />
401+
</div>
402+
<div className="relative flex justify-center text-xs uppercase">
403+
<span className="bg-background px-2 text-muted-foreground">
404+
Or enter manually
405+
</span>
406+
</div>
407+
</div>
408+
409+
{/* Text Input Section - Second */}
410+
<div className="space-y-4">
411+
<div>
412+
<h3 className="mb-2 font-semibold">
413+
Enter Addresses and Amounts
414+
</h3>
415+
<p className="mb-3 text-muted-foreground text-sm">
416+
Enter one address and amount on each line. Supports various
417+
formats. (space, comma, or =)
418+
</p>
419+
<div className="space-y-3">
420+
<Textarea
421+
placeholder={`0x314ab97b76e39d63c78d5c86c2daf8eaa306b182 3.141592
422+
thirdweb.eth,2.7182
423+
0x141ca95b6177615fb1417cf70e930e102bf8f384=1.41421`}
424+
value={textInput}
425+
onChange={(e) => setTextInput(e.target.value)}
426+
className="min-h-[120px] font-mono text-sm"
427+
onKeyDown={(e) => {
428+
if (e.key === "Enter" && e.ctrlKey) {
429+
e.preventDefault();
430+
handleTextSubmit();
431+
}
432+
}}
433+
/>
434+
<Button
435+
onClick={handleTextSubmit}
436+
disabled={!textInput.trim()}
437+
className="w-full"
438+
>
439+
Enter
440+
</Button>
335441
</div>
336442
</div>
337443
</div>
338-
<div className="h-6" />
339-
<CSVFormatDetails />
340444
</div>
341445
)}
342446
</div>

apps/dashboard/src/hooks/useCsvUpload.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ export function useCsvUpload<
161161
// Also filteredData's type is the superset of T[]
162162
setRawData(filteredData as unknown as T[]);
163163
}, [normalizeQuery.data?.result]);
164+
165+
const processData = useCallback(
166+
(data: T[]) => {
167+
setNoCsv(false);
168+
const processedData = props.csvParser(data);
169+
if (!processedData[0]?.address) {
170+
setNoCsv(true);
171+
return;
172+
}
173+
setRawData(processedData);
174+
},
175+
[props.csvParser],
176+
);
177+
164178
return {
165179
normalizeQuery,
166180
getInputProps,
@@ -170,5 +184,6 @@ export function useCsvUpload<
170184
noCsv,
171185
reset,
172186
removeInvalid,
187+
processData,
173188
};
174189
}

0 commit comments

Comments
 (0)