Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c130861
feat: add reset button for allowlist entries
tnkshuuhei Feb 23, 2025
de30acb
feat: edit allowlist via url
tnkshuuhei Feb 23, 2025
71fddc7
chore: install papaparse to parse csv
tnkshuuhei Feb 23, 2025
ae0dc57
feat: implement allowlist fetching from URL
tnkshuuhei Mar 17, 2025
e928240
feat: scale units
tnkshuuhei Mar 17, 2025
5e73039
feat: improve allowlist creation dialog
tnkshuuhei Mar 17, 2025
18f706a
fix: ensure handling of BigInt for allowlist units
tnkshuuhei Mar 18, 2025
61f1fea
style: change button layout
tnkshuuhei Mar 18, 2025
bf64fd5
feat: refactor allowlist fetching logic
tnkshuuhei Mar 20, 2025
3234cec
chore: typo
tnkshuuhei Mar 20, 2025
224becc
refactor: allowlist parsing and error handling
tnkshuuhei Mar 20, 2025
a72973f
test: add unit tests for parseAllowList function
tnkshuuhei Mar 20, 2025
847ea9c
chore: remove unused import
pheuberger Mar 7, 2025
3fdfce3
style: reorder imports
pheuberger Mar 7, 2025
fb9457d
chore: remove unused parameters
pheuberger Mar 7, 2025
e4be403
chore: remove WIP TODOs
pheuberger Mar 7, 2025
3a0cd59
refactor: use destructured function reference
pheuberger Mar 7, 2025
90301fc
chore: add TODO to extra-content.tsx
pheuberger Mar 11, 2025
83bc3c3
feat(profile): show warning banner contract account
pheuberger Mar 15, 2025
b51686c
Merge pull request #470 from hypercerts-org/feat/allowlist-creation-i…
tnkshuuhei Mar 25, 2025
025eb79
Merge pull request #491 from pheuberger/cherry-pick-warning-banner-fr…
pheuberger Mar 25, 2025
830dd85
feat: add Safe support to minting form
pheuberger Mar 11, 2025
ecf41c0
chore: fix leading whitespace
pheuberger Mar 25, 2025
8cee6c7
Merge pull request #484 from pheuberger/safe-support-hypercert-minting
pheuberger Mar 25, 2025
d5c24e4
feat: add Safe support for claiming hypercerts
pheuberger Mar 25, 2025
611e3ef
fix: showing switch chain when disconnected
pheuberger Mar 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/profile/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CollectionsTabContent } from "@/app/profile/[address]/collections-tab-c
import { MarketplaceTabContent } from "@/app/profile/[address]/marketplace-tab-content";
import { BlueprintsTabContent } from "@/app/profile/[address]/blueprint-tab-content";

import { ContractAccountBanner } from "@/components/profile/contract-accounts-banner";
export default function ProfilePage({
params,
searchParams,
Expand All @@ -22,6 +23,7 @@ export default function ProfilePage({

return (
<section className="flex flex-col gap-2">
<ContractAccountBanner address={address} />
<section className="flex flex-wrap gap-2 items-center">
<h1 className="font-serif text-3xl lg:text-5xl tracking-tight">
Profile
Expand Down
21 changes: 21 additions & 0 deletions components/allowlist/create-allowlist-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ const defaultValues = [

export default function Component({
setAllowlistEntries,
setAllowlistURL,
allowlistURL,
setOpen,
open,
initialValues,
}: {
setAllowlistEntries: (allowlistEntries: AllowlistEntry[]) => void;
setAllowlistURL: (allowlistURL: string) => void;
allowlistURL: string | undefined;
setOpen: (open: boolean) => void;
initialValues?: AllowListItem[];
open: boolean;
Expand All @@ -55,6 +59,16 @@ export default function Component({
initialValues?.length ? initialValues : defaultValues,
);

useEffect(() => {
if (open && !allowList[0].address && !allowList[0].percentage) {
if (initialValues && initialValues.length > 0) {
setAllowList(initialValues);
} else {
setAllowList(defaultValues);
}
}
}, [open]);

useEffect(() => {
if (validateAllowlistResponse?.success) {
(async () => {
Expand Down Expand Up @@ -141,6 +155,7 @@ export default function Component({
throw new Error("Allow list is empty");
}
validateAllowlist({ allowList: parsedAllowList, totalUnits });
setAllowlistURL("");
} catch (e) {
if (errorHasMessage(e)) {
toast({
Expand Down Expand Up @@ -264,6 +279,12 @@ export default function Component({
</Button>
</div>
</div>
{allowlistURL && (
<p className="text-sm text-red-600">
If you edit an original allowlist imported via URL, the original
allowlist will be deleted.
</p>
)}
<CreateAllowListErrorMessage />
<div className="flex gap-2 justify-evenly w-full">
<Button
Expand Down
2 changes: 2 additions & 0 deletions components/global/extra-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface ExtraContentProps {
receipt?: TransactionReceipt;
}

// TODO: not really reusable for safe. breaks when minting hypercert from safe.
// We should make this reusable for all strategies.
export function ExtraContent({
message = "Your hypercert has been minted successfully!",
hypercertId,
Expand Down
195 changes: 180 additions & 15 deletions components/hypercert/hypercert-minting-form/form-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import {
ArrowRightIcon,
CalendarIcon,
Loader2,
LoaderCircle,
Trash2Icon,
X,
} from "lucide-react";
import { RefObject, useMemo, useState } from "react";
import { RefObject, useEffect, useMemo, useState } from "react";
import rehypeSanitize from "rehype-sanitize";

import CreateAllowlistDialog from "@/components/allowlist/create-allowlist-dialog";
Expand Down Expand Up @@ -74,7 +75,11 @@ import Link from "next/link";
import { UseFormReturn } from "react-hook-form";
import { useAccount, useChainId } from "wagmi";
import { ImageUploader, readAsBase64 } from "@/components/image-uploader";

import { useValidateAllowlist } from "@/hypercerts/hooks/useCreateAllowLists";
import Papa from "papaparse";
import { getAddress, parseUnits } from "viem";
import { errorHasMessage } from "@/lib/errorHasMessage";
import { errorToast } from "@/lib/errorToast";
// import Image from "next/image";

interface FormStepsProps {
Expand Down Expand Up @@ -418,6 +423,50 @@ const DatesAndPeople = ({ form }: FormStepsProps) => {
);
};

export const parseAllowList = async (data: Response | string) => {
let allowList: AllowlistEntry[];
try {
const text = typeof data === "string" ? data : await data.text();
const parsedData = Papa.parse<AllowlistEntry>(text, {
header: true,
skipEmptyLines: true,
});

const validEntries = parsedData.data.filter(
(entry) => entry.address && entry.units,
);

// Calculate total units
const total = validEntries.reduce(
(sum, entry) => sum + BigInt(entry.units),
BigInt(0),
);

allowList = validEntries.map((entry) => {
const address = getAddress(entry.address);
const originalUnits = BigInt(entry.units);
// Scale units proportionally to DEFAULT_NUM_UNITS
const scaledUnits =
total > 0 ? (originalUnits * DEFAULT_NUM_UNITS) / total : BigInt(0);

return {
address: address,
units: scaledUnits,
};
});

return allowList;
} catch (e) {
if (errorHasMessage(e)) {
errorToast(e.message);
throw new Error(e.message);
} else {
errorToast("Failed to parse allowlist.");
throw new Error("Failed to parse allowlist.");
}
}
};

const calculatePercentageBigInt = (
units: bigint,
total: bigint = DEFAULT_NUM_UNITS,
Expand All @@ -433,22 +482,46 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
setOpen,
setTitle,
} = useStepProcessDialogContext();
const {
mutate: validateAllowlist,
data: validateAllowlistResponse,
isPending: isPendingValidateAllowlist,
error: createAllowListError,
} = useValidateAllowlist();
const [isUploading, setIsUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const setAllowlistURL = (allowlistURL: string) => {
form.setValue("allowlistURL", allowlistURL);
};
const setAllowlistEntries = (allowlistEntries: AllowlistEntry[]) => {
form.setValue("allowlistEntries", allowlistEntries);
};

const allowlistEntries = form.watch("allowlistEntries");

const errorToast = (message: string | undefined) => {
toast({
title: message,
variant: "destructive",
duration: 2000,
});
};
useEffect(() => {
if (createAllowListError) {
toast({
title: "Error",
description: createAllowListError.message,
variant: "destructive",
});
}
}, [createAllowListError]);

useEffect(() => {
if (validateAllowlistResponse?.success) {
const bigintUnits = validateAllowlistResponse.values.map(
(entry: AllowlistEntry) => ({
...entry,
units: BigInt(entry.units),
}),
);
form.setValue("allowlistEntries", bigintUnits);
}
}, [validateAllowlistResponse]);

async function validateFile(file: File) {
if (file.size > MAX_FILE_SIZE) {
Expand Down Expand Up @@ -505,6 +578,61 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
}
};

const fetchAllowlist = async (value: string) => {
let data: Response;
const url = value.startsWith("ipfs://")
? `https://ipfs.io/ipfs/${value.replace("ipfs://", "")}`
: value.startsWith("https://")
? value
: null;

if (!url) {
errorToast("Invalid URL. URL must start with 'https://' or 'ipfs://'");
throw new Error(
"Invalid URL. URL must start with 'https://' or 'ipfs://'",
);
}
data = await fetch(url);

const contentType = data.headers.get("content-type");

if (
contentType?.includes("text/csv") ||
contentType?.includes("text/plain") ||
value.endsWith(".csv")
) {
const allowList = await parseAllowList(data);
if (!allowList || allowList.length === 0) return;

const totalUnits = DEFAULT_NUM_UNITS;

// validateAllowlist
try {
validateAllowlist({
allowList,
totalUnits,
});
form.setValue("allowlistEntries", allowList);
} catch (e) {
if (errorHasMessage(e)) {
toast({
title: "Error",
description: e.message,
variant: "destructive",
});
} else {
toast({
title: "Error",
description: "Failed to upload allow list",
});
}
}
} else {
errorToast("Invalid file type.");
throw new Error("Invalid file type.");
}
};

return (
<section className="space-y-8">
{isBlueprint && (
Expand Down Expand Up @@ -549,7 +677,7 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
name="allowlistURL"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<div className="flex flex-row items-center gap-2">
<FormLabel>Allowlist (optional)</FormLabel>
<TooltipInfo
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."
Expand All @@ -559,8 +687,8 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
<FormControl>
<Input
{...field}
value={field.value}
placeholder="https:// | ipfs://"
disabled={!!allowlistEntries}
/>
</FormControl>
<FormMessage />
Expand All @@ -571,21 +699,58 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
<div className="flex text-xs space-x-2 w-full justify-end">
<Button
type="button"
disabled={!!field.value}
variant="outline"
disabled={
(form.getValues("allowlistEntries")?.length ?? 0) > 0 ||
!field.value
}
onClick={() => fetchAllowlist(field?.value as string)}
>
Import from URL
</Button>
<Button
type="button"
disabled={isPendingValidateAllowlist}
variant="outline"
onClick={() => setCreateDialogOpen(true)}
>
{allowlistEntries ? "Edit allowlist" : "Create allowlist"}
{isPendingValidateAllowlist ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin mr-2" />
Loading...
</>
) : allowlistEntries || field.value ? (
"Edit allowlist"
) : (
"New allowlist"
)}
</Button>

<Button
type="button"
disabled={
(!allowlistEntries && !field.value) ||
isPendingValidateAllowlist
}
onClick={() => {
form.setValue("allowlistEntries", undefined);
form.setValue("allowlistURL", "");
}}
>
<Trash2Icon className="w-4 h-4 mr-2" />
Delete
</Button>

<CreateAllowlistDialog
setAllowlistEntries={setAllowlistEntries}
setAllowlistURL={setAllowlistURL}
allowlistURL={field?.value}
open={createDialogOpen}
setOpen={setCreateDialogOpen}
initialValues={allowlistEntries?.map((entry) => ({
address: entry.address,
percentage: calculatePercentageBigInt(
entry.units,
BigInt(entry.units),
).toString(),
}))}
/>
Expand All @@ -610,7 +775,7 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
</TableCell>
<TableCell>
{formatNumber(
calculatePercentageBigInt(entry.units),
calculatePercentageBigInt(BigInt(entry.units)),
)}
%
</TableCell>
Expand Down
18 changes: 18 additions & 0 deletions components/profile/contract-accounts-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { useIsContract } from "@/hooks/useIsContract";

export function ContractAccountBanner({ address }: { address: string }) {
const { isContract, isLoading } = useIsContract(address);

if (!isContract || isLoading) return null;

return (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<div className="text-sm text-yellow-700">
This is a smart contract address. Contract ownership may vary across
networks. Please verify ownership details for each network.
</div>
</div>
);
}
Loading