@@ -23,6 +23,7 @@ import {
2323 TableHeader ,
2424 TableRow ,
2525} from "@/components/ui/table" ;
26+ import { Textarea } from "@/components/ui/textarea" ;
2627import { cn } from "@/lib/utils" ;
2728import { useCsvUpload } from "hooks/useCsvUpload" ;
2829import {
@@ -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
206236const 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