Skip to content

Commit 13307e5

Browse files
committed
Add text input option to airdrop setup
- 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
1 parent 3678054 commit 13307e5

File tree

1 file changed

+203
-77
lines changed
  • apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution

1 file changed

+203
-77
lines changed

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

Lines changed: 203 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,52 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
216248
noCsv,
217249
reset,
218250
removeInvalid,
219-
} = useCsvUpload<AirdropAddressInput>({ csvParser, client });
251+
} = useCsvUpload<AirdropAddressInput>({
252+
csvParser: (items: AirdropAddressInput[]) => {
253+
return items
254+
.map(({ address, quantity }) => ({
255+
address: (address || "").trim(),
256+
quantity: (quantity || "1").trim(),
257+
}))
258+
.filter(({ address }) => address !== "");
259+
},
260+
client,
261+
});
220262

221263
const normalizeData = normalizeQuery.data;
222264

223-
if (!normalizeData) {
265+
// Handle text input - create CSV and trigger file input
266+
const handleTextSubmit = () => {
267+
if (!textInput.trim()) return;
268+
269+
const parsedData = parseTextInput(textInput);
270+
271+
// Create CSV content
272+
const csvContent = `address,quantity\n${parsedData
273+
.map((item) => `${item.address},${item.quantity}`)
274+
.join("\n")}`;
275+
276+
// Create file and trigger the existing file input
277+
const blob = new Blob([csvContent], { type: "text/csv" });
278+
const file = new File([blob], "manual-input.csv", { type: "text/csv" });
279+
280+
// Get the file input and trigger change event
281+
const fileInput = document.querySelector(
282+
'input[type="file"]',
283+
) as HTMLInputElement;
284+
if (fileInput) {
285+
// Create a new FileList-like object
286+
const dataTransfer = new DataTransfer();
287+
dataTransfer.items.add(file);
288+
fileInput.files = dataTransfer.files;
289+
290+
// Trigger change event
291+
const event = new Event("change", { bubbles: true });
292+
fileInput.dispatchEvent(event);
293+
}
294+
};
295+
296+
if (!normalizeData && rawData.length > 0) {
224297
return (
225298
<div className="flex h-[300px] w-full grow items-center justify-center rounded-lg border border-border">
226299
<Spinner className="size-10" />
@@ -229,6 +302,8 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
229302
}
230303

231304
const handleContinue = () => {
305+
if (!normalizeData) return;
306+
232307
setAirdrop(
233308
normalizeData.result.map((o) => ({
234309
address: o.resolvedAddress || o.address,
@@ -239,9 +314,16 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
239314
onClose();
240315
};
241316

317+
const handleReset = () => {
318+
reset();
319+
setTextInput("");
320+
};
321+
242322
return (
243323
<div className="flex w-full grow flex-col gap-6 overflow-hidden">
244-
{normalizeData.result.length && rawData.length > 0 ? (
324+
{normalizeData &&
325+
normalizeData.result.length > 0 &&
326+
rawData.length > 0 ? (
245327
<div className="flex grow flex-col overflow-hidden outline">
246328
{normalizeQuery.data.invalidFound && (
247329
<p className="mb-3 text-red-500 text-sm">
@@ -253,19 +335,12 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
253335
className="rounded-b-none"
254336
/>
255337
<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-
>
338+
<Button variant="outline" onClick={handleReset}>
263339
<RotateCcwIcon className="mr-2 size-4" />
264340
Reset
265341
</Button>
266342
{normalizeQuery.data.invalidFound ? (
267343
<Button
268-
disabled={rawData.length === 0}
269344
onClick={() => {
270345
removeInvalid();
271346
}}
@@ -274,69 +349,120 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
274349
Remove invalid addresses
275350
</Button>
276351
) : (
277-
<Button onClick={handleContinue} disabled={rawData.length === 0}>
352+
<Button onClick={handleContinue}>
278353
Continue <ArrowRightIcon className="ml-2 size-4" />
279354
</Button>
280355
)}
281356
</div>
282357
</div>
283358
) : (
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>
359+
<div className="flex flex-col gap-6">
360+
{/* CSV Upload Section - First */}
361+
<div className="space-y-4">
362+
<CSVFormatDetails />
363+
364+
<div className="relative w-full">
365+
<div
366+
className={cn(
367+
"flex h-[180px] cursor-pointer items-center justify-center rounded-md border border-dashed bg-card hover:border-active-border",
368+
noCsv &&
369+
"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",
308370
)}
371+
{...getRootProps()}
372+
>
373+
<input {...getInputProps()} accept=".csv" />
374+
<div className="flex flex-col items-center justify-center gap-3">
375+
{!noCsv && (
376+
<div className="flex flex-col items-center">
377+
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
378+
<UploadIcon className="size-5" />
379+
</div>
380+
<h2 className="mb-0.5 text-center font-medium text-lg">
381+
Upload CSV File
382+
</h2>
383+
<p className="text-center font-medium text-muted-foreground text-sm">
384+
Drag and drop your file or click here to upload
385+
</p>
386+
</div>
387+
)}
309388

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" />
389+
{noCsv && (
390+
<div className="flex flex-col items-center">
391+
<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">
392+
<XIcon className="size-5" />
393+
</div>
394+
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
395+
Invalid CSV
396+
</h2>
397+
<p className="text-balance text-center text-sm">
398+
Your CSV does not contain the "address" & "quantity"
399+
columns
400+
</p>
401+
402+
<Button
403+
className="relative z-50 mt-4"
404+
size="sm"
405+
onClick={(e) => {
406+
e.stopPropagation();
407+
reset();
408+
}}
409+
>
410+
Remove Invalid CSV
411+
</Button>
314412
</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-
)}
413+
)}
414+
</div>
415+
</div>
416+
</div>
417+
</div>
418+
419+
{/* Divider */}
420+
<div className="relative">
421+
<div className="absolute inset-0 flex items-center">
422+
<span className="w-full border-t" />
423+
</div>
424+
<div className="relative flex justify-center text-xs uppercase">
425+
<span className="bg-background px-2 text-muted-foreground">
426+
Or enter manually
427+
</span>
428+
</div>
429+
</div>
430+
431+
{/* Text Input Section - Second */}
432+
<div className="space-y-4">
433+
<div>
434+
<h3 className="mb-2 font-semibold">
435+
Enter Addresses and Amounts
436+
</h3>
437+
<p className="mb-3 text-muted-foreground text-sm">
438+
Enter one address and amount on each line. Supports various
439+
formats. (space, comma, or =)
440+
</p>
441+
<div className="space-y-3">
442+
<Textarea
443+
placeholder={`0x314ab97b76e39d63c78d5c86c2daf8eaa306b182 3.141592
444+
thirdweb.eth,2.7182
445+
0x141ca95b6177615fb1417cf70e930e102bf8f384=1.41421`}
446+
value={textInput}
447+
onChange={(e) => setTextInput(e.target.value)}
448+
className="min-h-[120px] font-mono text-sm"
449+
onKeyDown={(e) => {
450+
if (e.key === "Enter" && e.ctrlKey) {
451+
e.preventDefault();
452+
handleTextSubmit();
453+
}
454+
}}
455+
/>
456+
<Button
457+
onClick={handleTextSubmit}
458+
disabled={!textInput.trim()}
459+
className="w-full"
460+
>
461+
Enter
462+
</Button>
335463
</div>
336464
</div>
337465
</div>
338-
<div className="h-6" />
339-
<CSVFormatDetails />
340466
</div>
341467
)}
342468
</div>

0 commit comments

Comments
 (0)