@@ -17,10 +17,11 @@ import {
17
17
ArrowRightIcon ,
18
18
CalendarIcon ,
19
19
Loader2 ,
20
+ LoaderCircle ,
20
21
Trash2Icon ,
21
22
X ,
22
23
} from "lucide-react" ;
23
- import { RefObject , useMemo , useState } from "react" ;
24
+ import { RefObject , useEffect , useMemo , useState } from "react" ;
24
25
import rehypeSanitize from "rehype-sanitize" ;
25
26
26
27
import CreateAllowlistDialog from "@/components/allowlist/create-allowlist-dialog" ;
@@ -74,7 +75,11 @@ import Link from "next/link";
74
75
import { UseFormReturn } from "react-hook-form" ;
75
76
import { useAccount , useChainId } from "wagmi" ;
76
77
import { ImageUploader , readAsBase64 } from "@/components/image-uploader" ;
77
-
78
+ import { useValidateAllowlist } from "@/hypercerts/hooks/useCreateAllowLists" ;
79
+ import Papa from "papaparse" ;
80
+ import { getAddress , parseUnits } from "viem" ;
81
+ import { errorHasMessage } from "@/lib/errorHasMessage" ;
82
+ import { errorToast } from "@/lib/errorToast" ;
78
83
// import Image from "next/image";
79
84
80
85
interface FormStepsProps {
@@ -418,6 +423,50 @@ const DatesAndPeople = ({ form }: FormStepsProps) => {
418
423
) ;
419
424
} ;
420
425
426
+ export const parseAllowList = async ( data : Response | string ) => {
427
+ let allowList : AllowlistEntry [ ] ;
428
+ try {
429
+ const text = typeof data === "string" ? data : await data . text ( ) ;
430
+ const parsedData = Papa . parse < AllowlistEntry > ( text , {
431
+ header : true ,
432
+ skipEmptyLines : true ,
433
+ } ) ;
434
+
435
+ const validEntries = parsedData . data . filter (
436
+ ( entry ) => entry . address && entry . units ,
437
+ ) ;
438
+
439
+ // Calculate total units
440
+ const total = validEntries . reduce (
441
+ ( sum , entry ) => sum + BigInt ( entry . units ) ,
442
+ BigInt ( 0 ) ,
443
+ ) ;
444
+
445
+ allowList = validEntries . map ( ( entry ) => {
446
+ const address = getAddress ( entry . address ) ;
447
+ const originalUnits = BigInt ( entry . units ) ;
448
+ // Scale units proportionally to DEFAULT_NUM_UNITS
449
+ const scaledUnits =
450
+ total > 0 ? ( originalUnits * DEFAULT_NUM_UNITS ) / total : BigInt ( 0 ) ;
451
+
452
+ return {
453
+ address : address ,
454
+ units : scaledUnits ,
455
+ } ;
456
+ } ) ;
457
+
458
+ return allowList ;
459
+ } catch ( e ) {
460
+ if ( errorHasMessage ( e ) ) {
461
+ errorToast ( e . message ) ;
462
+ throw new Error ( e . message ) ;
463
+ } else {
464
+ errorToast ( "Failed to parse allowlist." ) ;
465
+ throw new Error ( "Failed to parse allowlist." ) ;
466
+ }
467
+ }
468
+ } ;
469
+
421
470
const calculatePercentageBigInt = (
422
471
units : bigint ,
423
472
total : bigint = DEFAULT_NUM_UNITS ,
@@ -433,22 +482,46 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
433
482
setOpen,
434
483
setTitle,
435
484
} = useStepProcessDialogContext ( ) ;
485
+ const {
486
+ mutate : validateAllowlist ,
487
+ data : validateAllowlistResponse ,
488
+ isPending : isPendingValidateAllowlist ,
489
+ error : createAllowListError ,
490
+ } = useValidateAllowlist ( ) ;
436
491
const [ isUploading , setIsUploading ] = useState ( false ) ;
437
492
const [ selectedFile , setSelectedFile ] = useState < File | null > ( null ) ;
438
493
const [ isAdvancedOpen , setIsAdvancedOpen ] = useState ( false ) ;
439
494
const [ createDialogOpen , setCreateDialogOpen ] = useState ( false ) ;
495
+ const setAllowlistURL = ( allowlistURL : string ) => {
496
+ form . setValue ( "allowlistURL" , allowlistURL ) ;
497
+ } ;
440
498
const setAllowlistEntries = ( allowlistEntries : AllowlistEntry [ ] ) => {
441
499
form . setValue ( "allowlistEntries" , allowlistEntries ) ;
442
500
} ;
501
+
443
502
const allowlistEntries = form . watch ( "allowlistEntries" ) ;
444
503
445
- const errorToast = ( message : string | undefined ) => {
446
- toast ( {
447
- title : message ,
448
- variant : "destructive" ,
449
- duration : 2000 ,
450
- } ) ;
451
- } ;
504
+ useEffect ( ( ) => {
505
+ if ( createAllowListError ) {
506
+ toast ( {
507
+ title : "Error" ,
508
+ description : createAllowListError . message ,
509
+ variant : "destructive" ,
510
+ } ) ;
511
+ }
512
+ } , [ createAllowListError ] ) ;
513
+
514
+ useEffect ( ( ) => {
515
+ if ( validateAllowlistResponse ?. success ) {
516
+ const bigintUnits = validateAllowlistResponse . values . map (
517
+ ( entry : AllowlistEntry ) => ( {
518
+ ...entry ,
519
+ units : BigInt ( entry . units ) ,
520
+ } ) ,
521
+ ) ;
522
+ form . setValue ( "allowlistEntries" , bigintUnits ) ;
523
+ }
524
+ } , [ validateAllowlistResponse ] ) ;
452
525
453
526
async function validateFile ( file : File ) {
454
527
if ( file . size > MAX_FILE_SIZE ) {
@@ -505,6 +578,61 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
505
578
}
506
579
} ;
507
580
581
+ const fetchAllowlist = async ( value : string ) => {
582
+ let data : Response ;
583
+ const url = value . startsWith ( "ipfs://" )
584
+ ? `https://ipfs.io/ipfs/${ value . replace ( "ipfs://" , "" ) } `
585
+ : value . startsWith ( "https://" )
586
+ ? value
587
+ : null ;
588
+
589
+ if ( ! url ) {
590
+ errorToast ( "Invalid URL. URL must start with 'https://' or 'ipfs://'" ) ;
591
+ throw new Error (
592
+ "Invalid URL. URL must start with 'https://' or 'ipfs://'" ,
593
+ ) ;
594
+ }
595
+ data = await fetch ( url ) ;
596
+
597
+ const contentType = data . headers . get ( "content-type" ) ;
598
+
599
+ if (
600
+ contentType ?. includes ( "text/csv" ) ||
601
+ contentType ?. includes ( "text/plain" ) ||
602
+ value . endsWith ( ".csv" )
603
+ ) {
604
+ const allowList = await parseAllowList ( data ) ;
605
+ if ( ! allowList || allowList . length === 0 ) return ;
606
+
607
+ const totalUnits = DEFAULT_NUM_UNITS ;
608
+
609
+ // validateAllowlist
610
+ try {
611
+ validateAllowlist ( {
612
+ allowList,
613
+ totalUnits,
614
+ } ) ;
615
+ form . setValue ( "allowlistEntries" , allowList ) ;
616
+ } catch ( e ) {
617
+ if ( errorHasMessage ( e ) ) {
618
+ toast ( {
619
+ title : "Error" ,
620
+ description : e . message ,
621
+ variant : "destructive" ,
622
+ } ) ;
623
+ } else {
624
+ toast ( {
625
+ title : "Error" ,
626
+ description : "Failed to upload allow list" ,
627
+ } ) ;
628
+ }
629
+ }
630
+ } else {
631
+ errorToast ( "Invalid file type." ) ;
632
+ throw new Error ( "Invalid file type." ) ;
633
+ }
634
+ } ;
635
+
508
636
return (
509
637
< section className = "space-y-8" >
510
638
{ isBlueprint && (
@@ -549,7 +677,7 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
549
677
name = "allowlistURL"
550
678
render = { ( { field } ) => (
551
679
< FormItem >
552
- < div className = "flex items-center gap-2" >
680
+ < div className = "flex flex-row items-center gap-2" >
553
681
< FormLabel > Allowlist (optional)</ FormLabel >
554
682
< TooltipInfo
555
683
tooltipText = "Allowlists determine the number of units each address is allowed to mint. You can create a new allowlist, or prefill from an existing, already uploaded file."
@@ -559,8 +687,8 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
559
687
< FormControl >
560
688
< Input
561
689
{ ...field }
562
- value = { field . value }
563
690
placeholder = "https:// | ipfs://"
691
+ disabled = { ! ! allowlistEntries }
564
692
/>
565
693
</ FormControl >
566
694
< FormMessage />
@@ -571,21 +699,58 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
571
699
< div className = "flex text-xs space-x-2 w-full justify-end" >
572
700
< Button
573
701
type = "button"
574
- disabled = { ! ! field . value }
702
+ variant = "outline"
703
+ disabled = {
704
+ ( form . getValues ( "allowlistEntries" ) ?. length ?? 0 ) > 0 ||
705
+ ! field . value
706
+ }
707
+ onClick = { ( ) => fetchAllowlist ( field ?. value as string ) }
708
+ >
709
+ Import from URL
710
+ </ Button >
711
+ < Button
712
+ type = "button"
713
+ disabled = { isPendingValidateAllowlist }
575
714
variant = "outline"
576
715
onClick = { ( ) => setCreateDialogOpen ( true ) }
577
716
>
578
- { allowlistEntries ? "Edit allowlist" : "Create allowlist" }
717
+ { isPendingValidateAllowlist ? (
718
+ < >
719
+ < LoaderCircle className = "h-4 w-4 animate-spin mr-2" />
720
+ Loading...
721
+ </ >
722
+ ) : allowlistEntries || field . value ? (
723
+ "Edit allowlist"
724
+ ) : (
725
+ "New allowlist"
726
+ ) }
727
+ </ Button >
728
+
729
+ < Button
730
+ type = "button"
731
+ disabled = {
732
+ ( ! allowlistEntries && ! field . value ) ||
733
+ isPendingValidateAllowlist
734
+ }
735
+ onClick = { ( ) => {
736
+ form . setValue ( "allowlistEntries" , undefined ) ;
737
+ form . setValue ( "allowlistURL" , "" ) ;
738
+ } }
739
+ >
740
+ < Trash2Icon className = "w-4 h-4 mr-2" />
741
+ Delete
579
742
</ Button >
580
743
581
744
< CreateAllowlistDialog
582
745
setAllowlistEntries = { setAllowlistEntries }
746
+ setAllowlistURL = { setAllowlistURL }
747
+ allowlistURL = { field ?. value }
583
748
open = { createDialogOpen }
584
749
setOpen = { setCreateDialogOpen }
585
750
initialValues = { allowlistEntries ?. map ( ( entry ) => ( {
586
751
address : entry . address ,
587
752
percentage : calculatePercentageBigInt (
588
- entry . units ,
753
+ BigInt ( entry . units ) ,
589
754
) . toString ( ) ,
590
755
} ) ) }
591
756
/>
@@ -610,7 +775,7 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
610
775
</ TableCell >
611
776
< TableCell >
612
777
{ formatNumber (
613
- calculatePercentageBigInt ( entry . units ) ,
778
+ calculatePercentageBigInt ( BigInt ( entry . units ) ) ,
614
779
) }
615
780
%
616
781
</ TableCell >
0 commit comments