diff --git a/apps/dashboard/src/@/actions/revalidate.ts b/apps/dashboard/src/@/actions/revalidate.ts new file mode 100644 index 00000000000..969cca0cfcc --- /dev/null +++ b/apps/dashboard/src/@/actions/revalidate.ts @@ -0,0 +1,10 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +export async function revalidatePathAction( + path: string, + type: "page" | "layout", +) { + revalidatePath(path, type); +} diff --git a/apps/dashboard/src/@/components/blocks/distribution-chart.tsx b/apps/dashboard/src/@/components/blocks/distribution-chart.tsx new file mode 100644 index 00000000000..186f8671cf4 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/distribution-chart.tsx @@ -0,0 +1,71 @@ +import { cn } from "@/lib/utils"; + +export type Segment = { + label: string; + percent: number; + color: string; +}; + +type DistributionBarChartProps = { + segments: Segment[]; + title: string; +}; +export function DistributionBarChart(props: DistributionBarChartProps) { + const totalPercentage = props.segments.reduce( + (sum, segment) => sum + segment.percent, + 0, + ); + + const invalidTotalPercentage = totalPercentage !== 100; + + return ( +
+
+

{props.title}

+
+ Total: {totalPercentage}% +
+
+ + {/* Bar */} +
+ {props.segments.map((segment) => { + return ( +
+ ); + })} +
+ + {/* Legends */} +
+ {props.segments.map((segment) => { + return ( +
+
+

+ {segment.label}: {segment.percent}% +

+
+ ); + })} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx new file mode 100644 index 00000000000..451966439bc --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MultiStepStatus } from "./multi-step-status"; + +const meta = { + title: "Blocks/MultiStepStatus", + component: MultiStepStatus, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const AllStates: Story = { + args: { + steps: [ + { + status: "completed", + label: "Connect Wallet", + retryLabel: "Failed to connect wallet", + execute: async () => { + await sleep(1000); + }, + }, + { + status: "pending", + label: "Sign Message", + retryLabel: "Failed to sign message", + execute: async () => { + await sleep(1000); + }, + }, + { + status: "error", + label: "Approve Transaction", + retryLabel: "Transaction approval failed", + execute: async () => { + await sleep(1000); + }, + }, + { + status: "idle", + label: "Confirm Transaction", + retryLabel: "Transaction confirmation failed", + execute: async () => { + await sleep(1000); + }, + }, + { + status: "idle", + label: "Finalize", + retryLabel: "Finalization failed", + execute: async () => { + await sleep(1000); + }, + }, + ], + }, +}; diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx new file mode 100644 index 00000000000..2a8cf090184 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + AlertCircleIcon, + CircleCheckIcon, + CircleIcon, + RefreshCwIcon, +} from "lucide-react"; +import { DynamicHeight } from "../../ui/DynamicHeight"; +import { Spinner } from "../../ui/Spinner/Spinner"; + +export type MultiStepState = { + status: "idle" | "pending" | "completed" | "error"; + retryLabel: string; + label: string; + execute: () => Promise; +}; + +export function MultiStepStatus(props: { + steps: MultiStepState[]; +}) { + return ( + +
+ {props.steps.map((step) => ( +
+ {step.status === "completed" ? ( + + ) : step.status === "pending" ? ( + + ) : step.status === "error" ? ( + + ) : ( + + )} +
+

+ {step.label} +

+ + {step.status === "error" && ( +
+

{step.retryLabel}

+ +
+ )} +
+
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx index b3a71cabb3b..f3b26340cc2 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx @@ -126,8 +126,10 @@ const getClaimConditionTypeFromPhase = ( if (!phase.snapshot) { return "public"; } + if (phase.snapshot) { if ( + phase.price === "0" && typeof phase.snapshot !== "string" && phase.snapshot.length === 1 && phase.snapshot.some( diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx index 0f2b2020134..b5bdb8505ba 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx @@ -147,6 +147,8 @@ function AddToProjectModalContent(props: { teamId: params.teamId, projectId: params.projectId, chainId: props.chainId, + deploymentType: undefined, + contractType: undefined, }, { onSuccess: () => { diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply-layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply-layout.tsx index 78aaf63f80a..83da520d1be 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply-layout.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply-layout.tsx @@ -14,7 +14,7 @@ export function TokenDetailsCardUI(props: {
diff --git a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx index 4ebeb25f0cb..f6773cc4135 100644 --- a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx +++ b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx @@ -19,6 +19,7 @@ export function DeployViaCLIOrImportCard(props: { return (
{ diff --git a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx index c4784cdef6b..cc3f9dc0c32 100644 --- a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx +++ b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx @@ -44,11 +44,13 @@ async function DeployedContractsPageAsync(props: { teamId: props.teamId, projectId: props.projectId, authToken: props.authToken, + deploymentType: undefined, }); return ( }> c.chainId))); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/cards.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/cards.tsx new file mode 100644 index 00000000000..5710bd0ee6a --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/cards.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { ImportModal } from "components/contract-components/import-contract/modal"; +import { useTrack } from "hooks/analytics/useTrack"; +import { ArrowDownToLineIcon, CoinsIcon, ImagesIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; + +export function Cards(props: { + teamSlug: string; + projectSlug: string; + client: ThirdwebClient; + teamId: string; + projectId: string; +}) { + const [importModalOpen, setImportModalOpen] = useState(false); + + return ( +
+ { + setImportModalOpen(false); + }} + teamId={props.teamId} + projectId={props.projectId} + type="asset" + /> + + + + + + { + setImportModalOpen(true); + }} + /> +
+ ); +} + +function CardLink(props: { + title: string; + description: string; + href: string | undefined; + onClick?: () => void; + icon: React.FC<{ className?: string }>; + trackingLabel: string; + badge?: string; +}) { + const { onClick } = props; + const isClickable = !!onClick || !!props.href; + const trackEvent = useTrack(); + + function handleClick() { + trackEvent({ + category: "assets-landing-page", + action: "click", + label: props.trackingLabel, + }); + onClick?.(); + } + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + handleClick(); + } + } + : undefined + } + > +
+
+ +
+
+ + {props.badge && ( +
+ + {props.badge} + +
+ )} + +

+ {props.href ? ( + + {props.title} + + ) : ( + {props.title} + )} +

+

{props.description}

+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-card.tsx new file mode 100644 index 00000000000..fbcf382cda3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-card.tsx @@ -0,0 +1,79 @@ +import { Button } from "@/components/ui/button"; +import { useTrack } from "hooks/analytics/useTrack"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { getStepCardTrackingData } from "./tracking"; + +export function StepCard(props: { + title: string; + page: "info" | "distribution" | "launch"; + prevButton: + | undefined + | { + onClick: () => void; + }; + nextButton: + | undefined + | { + type: "submit"; + } + | { + type: "custom"; + custom: React.ReactNode; + }; + children: React.ReactNode; +}) { + const trackEvent = useTrack(); + return ( +
+

+ {props.title} +

+ + {props.children} + +
+ {props.prevButton && ( + + )} + + {props.nextButton && props.nextButton.type === "submit" && ( + + )} + + {props.nextButton && + props.nextButton.type === "custom" && + props.nextButton.custom} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx new file mode 100644 index 00000000000..ccb7f70418c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx @@ -0,0 +1,362 @@ +"use client"; +import { revalidatePathAction } from "@/actions/revalidate"; +import { + DEFAULT_FEE_BPS_NEW, + DEFAULT_FEE_RECIPIENT, +} from "constants/addresses"; +import { useTrack } from "hooks/analytics/useTrack"; +import { useAllChainsData } from "hooks/chains/allChains"; +import { defineDashboardChain } from "lib/defineDashboardChain"; +import { useRef } from "react"; +import { toast } from "sonner"; +import { + type ThirdwebClient, + getContract, + sendAndConfirmTransaction, + toUnits, +} from "thirdweb"; +import { deployERC20Contract } from "thirdweb/deploys"; +import type { ClaimConditionsInput } from "thirdweb/dist/types/utils/extensions/drops/types"; +import { + claimTo, + setClaimConditions as setClaimConditionsExtension, + transferBatch, +} from "thirdweb/extensions/erc20"; +import { useActiveAccount } from "thirdweb/react"; +import { useAddContractToProject } from "../../hooks/project-contracts"; +import { CreateTokenAssetPageUI } from "./create-token-page.client"; +import type { CreateAssetFormValues } from "./form"; +import { + getTokenDeploymentTrackingData, + getTokenStepTrackingData, +} from "./tracking"; + +export function CreateTokenAssetPage(props: { + accountAddress: string; + client: ThirdwebClient; + teamId: string; + projectId: string; +}) { + const account = useActiveAccount(); + const { idToChain } = useAllChainsData(); + const addContractToProject = useAddContractToProject(); + const contractAddressRef = useRef(undefined); + const trackEvent = useTrack(); + + async function deployContract(formValues: CreateAssetFormValues) { + if (!account) { + toast.error("No Connected Wallet"); + throw new Error("No Connected Wallet"); + } + + trackEvent( + getTokenDeploymentTrackingData("attempt", Number(formValues.chain)), + ); + + const socialUrls = formValues.socialUrls.reduce( + (acc, url) => { + if (url.url && url.platform) { + acc[url.platform] = url.url; + } + return acc; + }, + {} as Record, + ); + + try { + const contractAddress = await deployERC20Contract({ + account, + // eslint-disable-next-line no-restricted-syntax + chain: defineDashboardChain( + Number(formValues.chain), + idToChain.get(Number(formValues.chain)), + ), + client: props.client, + type: "DropERC20", + params: { + // metadata + name: formValues.name, + description: formValues.description, + symbol: formValues.symbol, + image: formValues.image, + // platform fees + platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW), + platformFeeRecipient: DEFAULT_FEE_RECIPIENT, + // primary sale + saleRecipient: account.address, + social_urls: socialUrls, + }, + }); + + trackEvent( + getTokenDeploymentTrackingData("success", Number(formValues.chain)), + ); + + // add contract to project in background + addContractToProject.mutateAsync({ + teamId: props.teamId, + projectId: props.projectId, + contractAddress: contractAddress, + chainId: formValues.chain, + deploymentType: "asset", + contractType: "DropERC20", + }); + + contractAddressRef.current = contractAddress; + + return { + contractAddress: contractAddress, + }; + } catch (e) { + trackEvent( + getTokenDeploymentTrackingData("error", Number(formValues.chain)), + ); + throw e; + } + } + + async function airdropTokens(formValues: CreateAssetFormValues) { + const contractAddress = contractAddressRef.current; + + if (!contractAddress) { + throw new Error("No contract address"); + } + + if (!account) { + throw new Error("No connected account"); + } + + // eslint-disable-next-line no-restricted-syntax + const chain = defineDashboardChain( + Number(formValues.chain), + idToChain.get(Number(formValues.chain)), + ); + + const contract = getContract({ + client: props.client, + address: contractAddress, + chain, + }); + + trackEvent( + getTokenStepTrackingData({ + action: "airdrop", + chainId: Number(formValues.chain), + status: "attempt", + }), + ); + + try { + const airdropTx = transferBatch({ + contract, + batch: formValues.airdropAddresses.map((recipient) => ({ + to: recipient.address, + amount: recipient.quantity, + })), + }); + + await sendAndConfirmTransaction({ + transaction: airdropTx, + account, + }); + + trackEvent( + getTokenStepTrackingData({ + action: "airdrop", + chainId: Number(formValues.chain), + status: "success", + }), + ); + } catch (e) { + trackEvent( + getTokenStepTrackingData({ + action: "airdrop", + chainId: Number(formValues.chain), + status: "error", + }), + ); + throw e; + } + } + + async function mintTokens(formValues: CreateAssetFormValues) { + const contractAddress = contractAddressRef.current; + if (!contractAddress) { + throw new Error("No contract address"); + } + + if (!account) { + throw new Error("No connected account"); + } + + // eslint-disable-next-line no-restricted-syntax + const chain = defineDashboardChain( + Number(formValues.chain), + idToChain.get(Number(formValues.chain)), + ); + + const contract = getContract({ + client: props.client, + address: contractAddress, + chain, + }); + + const totalSupply = Number(formValues.supply); + const salePercent = formValues.saleEnabled + ? Number(formValues.saleAllocationPercentage) + : 0; + + const ownerAndAirdropPercent = 100 - salePercent; + const ownerSupplyTokens = (totalSupply * ownerAndAirdropPercent) / 100; + + trackEvent( + getTokenStepTrackingData({ + action: "mint", + chainId: Number(formValues.chain), + status: "attempt", + }), + ); + + try { + const claimTx = claimTo({ + contract, + to: account.address, + quantity: ownerSupplyTokens.toString(), + }); + + await sendAndConfirmTransaction({ + transaction: claimTx, + account, + }); + + trackEvent( + getTokenStepTrackingData({ + action: "mint", + chainId: Number(formValues.chain), + status: "success", + }), + ); + } catch (e) { + trackEvent( + getTokenStepTrackingData({ + action: "mint", + chainId: Number(formValues.chain), + status: "error", + }), + ); + throw e; + } + } + + async function setClaimConditions(formValues: CreateAssetFormValues) { + const contractAddress = contractAddressRef.current; + + if (!contractAddress) { + throw new Error("No contract address"); + } + + if (!account) { + throw new Error("No connected account"); + } + + // eslint-disable-next-line no-restricted-syntax + const chain = defineDashboardChain( + Number(formValues.chain), + idToChain.get(Number(formValues.chain)), + ); + + const contract = getContract({ + client: props.client, + address: contractAddress, + chain, + }); + + const salePercent = formValues.saleEnabled + ? Number(formValues.saleAllocationPercentage) + : 0; + + const totalSupply = Number(formValues.supply); + const totalSupplyWei = toUnits(totalSupply.toString(), 18); + + const phases: ClaimConditionsInput[] = [ + { + maxClaimablePerWallet: formValues.saleEnabled ? undefined : 0n, + maxClaimableSupply: totalSupplyWei, + price: + formValues.saleEnabled && salePercent > 0 + ? formValues.salePrice + : "0", + startTime: new Date(), + metadata: { + name: + formValues.saleEnabled && salePercent > 0 + ? "Token Sale phase" + : "Only Owner phase", + }, + overrideList: [ + { + address: account.address, + maxClaimable: "unlimited", + price: "0", + }, + ], + }, + ]; + + const preparedTx = setClaimConditionsExtension({ + contract, + phases, + }); + + trackEvent( + getTokenStepTrackingData({ + action: "claim-conditions", + chainId: Number(formValues.chain), + status: "attempt", + }), + ); + + try { + await sendAndConfirmTransaction({ + transaction: preparedTx, + account, + }); + + trackEvent( + getTokenStepTrackingData({ + action: "claim-conditions", + chainId: Number(formValues.chain), + status: "success", + }), + ); + } catch (e) { + trackEvent( + getTokenStepTrackingData({ + action: "claim-conditions", + chainId: Number(formValues.chain), + status: "error", + }), + ); + throw e; + } + } + + return ( + { + revalidatePathAction( + `/team/${props.teamId}/project/${props.projectId}/assets`, + "page", + ); + }} + createTokenFunctions={{ + deployContract, + airdropTokens, + mintTokens, + setClaimConditions, + }} + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx new file mode 100644 index 00000000000..8724e7f4052 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx @@ -0,0 +1,119 @@ +"use client"; + +import {} from "@/components/blocks/multi-step-status/multi-step-status"; +import {} from "@/components/ui/dialog"; +import { zodResolver } from "@hookform/resolvers/zod"; +import {} from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import type { ThirdwebClient } from "thirdweb"; +import { TokenDistributionFieldset } from "./distribution/token-distribution"; +import { + type CreateAssetFormValues, + type TokenDistributionFormValues, + type TokenInfoFormValues, + tokenDistributionFormSchema, + tokenInfoFormSchema, +} from "./form"; +import { LaunchTokenStatus } from "./launch/launch-token"; +import { TokenInfoFieldset } from "./token-info-fieldset"; + +export type CreateTokenFunctions = { + deployContract: (values: CreateAssetFormValues) => Promise<{ + contractAddress: string; + }>; + setClaimConditions: (values: CreateAssetFormValues) => Promise; + mintTokens: (values: CreateAssetFormValues) => Promise; + airdropTokens: (values: CreateAssetFormValues) => Promise; +}; + +export function CreateTokenAssetPageUI(props: { + accountAddress: string; + client: ThirdwebClient; + createTokenFunctions: CreateTokenFunctions; + onLaunchSuccess: () => void; +}) { + const [step, setStep] = useState<"token-info" | "distribution" | "launch">( + "token-info", + ); + + const tokenInfoForm = useForm({ + resolver: zodResolver(tokenInfoFormSchema), + values: { + name: "", + description: "", + symbol: "", + image: undefined, + chain: "1", + socialUrls: [ + { + platform: "Website", + url: "", + }, + { + platform: "Twitter", + url: "", + }, + ], + }, + reValidateMode: "onChange", + }); + + const tokenDistributionForm = useForm({ + resolver: zodResolver(tokenDistributionFormSchema), + values: { + // sale fieldset + saleAllocationPercentage: "0", + salePrice: "0.1", + supply: "1000000", + saleEnabled: false, + // airdrop + airdropEnabled: false, + airdropAddresses: [], + }, + reValidateMode: "onChange", + }); + + return ( +
+ {step === "token-info" && ( + { + setStep("distribution"); + }} + /> + )} + + {step === "distribution" && ( + { + setStep("token-info"); + }} + onNext={() => { + setStep("launch"); + }} + /> + )} + + {step === "launch" && ( + { + setStep("distribution"); + }} + createTokenFunctions={props.createTokenFunctions} + values={{ + ...tokenInfoForm.getValues(), + ...tokenDistributionForm.getValues(), + }} + /> + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.stories.tsx new file mode 100644 index 00000000000..a2a38e1c2be --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ConnectButton, ThirdwebProvider } from "thirdweb/react"; +import { storybookThirdwebClient } from "../../../../../../../stories/utils"; +import { CreateTokenAssetPageUI } from "./create-token-page.client"; + +const meta = { + title: "Assets/CreateTokenPage", + component: CreateTokenAssetPageUI, + decorators: [ + (Story) => ( + +
+ +
+ +
+ + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const mockCreateTokenFunctions = { + deployContract: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { contractAddress: "0x123" }; + }, + setClaimConditions: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }, + mintTokens: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }, + airdropTokens: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }, +}; + +export const Default: Story = { + args: { + accountAddress: "0x1234567890123456789012345678901234567890", + client: storybookThirdwebClient, + createTokenFunctions: mockCreateTokenFunctions, + onLaunchSuccess: () => {}, + }, +}; + +export const ErrorOnDeploy: Story = { + args: { + accountAddress: "0x1234567890123456789012345678901234567890", + client: storybookThirdwebClient, + onLaunchSuccess: () => {}, + createTokenFunctions: { + ...mockCreateTokenFunctions, + deployContract: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + throw new Error("Failed to deploy contract"); + }, + }, + }, +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-airdrop.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-airdrop.tsx new file mode 100644 index 00000000000..437f63e7b70 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-airdrop.tsx @@ -0,0 +1,437 @@ +/* eslint-disable @next/next/no-img-element */ +"use client"; + +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { InlineCode } from "@/components/ui/inline-code"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { useCsvUpload } from "hooks/useCsvUpload"; +import { + ArrowDownToLineIcon, + ArrowRightIcon, + ArrowUpFromLineIcon, + CircleAlertIcon, + FileTextIcon, + RotateCcwIcon, + UploadIcon, + XIcon, +} from "lucide-react"; +import { useState } from "react"; +import type { TokenDistributionForm } from "../form"; + +type AirdropAddressInput = { + address: string; + quantity: string; + isValid?: boolean; + resolvedAddress?: string; +}; + +export function TokenAirdropSection(props: { + form: TokenDistributionForm; +}) { + const airdropAddresses = props.form.watch("airdropAddresses"); + const [showUploadSheet, setShowUploadSheet] = useState(false); + const totalAirdropSupply = airdropAddresses.reduce( + (acc, curr) => acc + Number(curr.quantity), + 0, + ); + + const isEnabled = props.form.watch("airdropEnabled"); + + const removeAirdropAddresses = () => { + props.form.setValue("airdropAddresses", []); + }; + + return ( + +
+
+
+

Airdrop

+

+ Airdrop tokens to a list of addresses with each address receiving + a specific quantity +

+
+ + { + props.form.setValue("airdropEnabled", checked); + if (!checked) { + removeAirdropAddresses(); + } + }} + /> +
+ + {isEnabled && ( +
+ {airdropAddresses.length > 0 ? ( +
+ {/* left */} +
+

CSV File Uploaded

+

+ + {airdropAddresses.length} + {" "} + addresses will receive a total of{" "} + {totalAirdropSupply}{" "} + tokens +

+
+ + {/* right */} +
+ + + + + + + + + Airdrop CSV + + + +
+ +
+
+
+ + +
+
+ ) : ( +
+ + + + + Airdrop CSV File + + + Upload a CSV file to airdrop tokens to a list of + addresses + + + { + props.form.setValue("airdropAddresses", addresses); + setShowUploadSheet(false); + }} + onClose={() => setShowUploadSheet(false)} + /> + + + + +
+ )} +
+ )} +
+
+ ); +} + +type AirdropUploadProps = { + setAirdrop: (airdrop: AirdropAddressInput[]) => void; + onClose: () => void; +}; + +// CSV parser for airdrop data +const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => { + return items + .map(({ address, quantity }) => ({ + address: (address || "").trim(), + quantity: (quantity || "1").trim(), + })) + .filter(({ address }) => address !== ""); +}; + +const AirdropUpload: React.FC = ({ + setAirdrop, + onClose, +}) => { + const { + normalizeQuery, + getInputProps, + getRootProps, + rawData, + noCsv, + reset, + removeInvalid, + } = useCsvUpload({ csvParser }); + + const normalizeData = normalizeQuery.data; + + if (!normalizeData) { + return ( +
+ +
+ ); + } + + const handleContinue = () => { + setAirdrop( + normalizeData.result.map((o) => ({ + address: o.resolvedAddress || o.address, + quantity: o.quantity, + isValid: o.isValid, + })), + ); + onClose(); + }; + + return ( +
+ {normalizeData.result.length && rawData.length > 0 ? ( +
+ {normalizeQuery.data.invalidFound && ( +

+ Invalid addresses found. Please remove them before continuing. +

+ )} + +
+ + {normalizeQuery.data.invalidFound ? ( + + ) : ( + + )} +
+
+ ) : ( +
+
+
+ +
+ {!noCsv && ( +
+
+ +
+

+ Upload CSV File +

+

+ Drag and drop your file or click here to upload +

+
+ )} + + {noCsv && ( +
+
+ +
+

+ Invalid CSV +

+

+ Your CSV does not contain the "address" & "quantity" + columns +

+ + +
+ )} +
+
+
+
+ +
+ )} +
+ ); +}; + +function CSVFormatDetails() { + return ( +
+

CSV Format

+
    +
  • + CSV file must contain and + columns. If the quantity value is not + provided in a record, it will default to 1 token. +
  • +
  • + Repeated addresses will be removed and only the first found will be + kept. +
  • +
  • + ENS Name can be used as an address as well, Address will automatically + be resolved +
  • +
+ + +
+ ); +} + +function DownloadExampleCSVButton() { + return ( + + ); +} + +function AirdropTable(props: { + data: AirdropAddressInput[]; + className?: string; +}) { + return ( + + + + + Address + Quantity + + + + {props.data.map((item, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + + {item.isValid ? ( + item.address + ) : ( +
+ + + {item.address} + +
+ )} +
+ {item.quantity} +
+ ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-distribution.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-distribution.tsx new file mode 100644 index 00000000000..bf1dc45f00c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-distribution.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { + DistributionBarChart, + type Segment, +} from "@/components/blocks/distribution-chart"; +import { Input } from "@/components/ui/input"; +import {} from "lucide-react"; +import { Form } from "../../../../../../../../@/components/ui/form"; +import { StepCard } from "../create-token-card"; +import type { + TokenDistributionForm, + TokenDistributionFormValues, +} from "../form"; +import { TokenAirdropSection } from "./token-airdrop"; +import { TokenSaleSection } from "./token-sale"; + +export function TokenDistributionFieldset(props: { + accountAddress: string; + onNext: () => void; + onPrevious: () => void; + form: TokenDistributionForm; + chainId: string; +}) { + const { form } = props; + + return ( +
+ + +
+
+ +
+ + + Tokens + +
+
+ + +
+ + + +
+
+
+ + ); +} + +export function TokenDistributionBarChart(props: { + distributionFormValues: TokenDistributionFormValues; +}) { + const totalSupply = Number(props.distributionFormValues.supply); + const totalAirdropSupply = + props.distributionFormValues.airdropAddresses.reduce( + (acc, curr) => acc + Number(curr.quantity), + 0, + ); + const airdropPercentage = (totalAirdropSupply / totalSupply) * 100; + const salePercentage = Number( + props.distributionFormValues.saleAllocationPercentage, + ); + const ownerPercentage = 100 - airdropPercentage - salePercentage; + + const tokenAllocations: Segment[] = [ + { + label: "Owner", + percent: ownerPercentage, + color: "hsl(var(--chart-1))", + }, + { + label: "Airdrop", + percent: airdropPercentage, + color: "hsl(var(--chart-3))", + }, + { + label: "Sale", + percent: salePercentage, + color: "hsl(var(--chart-4))", + }, + ]; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx new file mode 100644 index 00000000000..40c1e6c2f37 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { useAllChainsData } from "hooks/chains/allChains"; +import type { TokenDistributionForm } from "../form"; + +export function TokenSaleSection(props: { + form: TokenDistributionForm; + chainId: string; +}) { + const totalSupply = Number(props.form.watch("supply")); + const sellSupply = Math.floor( + (totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100, + ); + const { idToChain } = useAllChainsData(); + const selectedChainMeta = idToChain.get(Number(props.chainId)); + + const isEnabled = props.form.watch("saleEnabled"); + return ( + +
+
+
+

Sale

+

+ Make your token available for purchase by setting a price +

+
+ + { + props.form.setValue("saleEnabled", checked); + if (!checked) { + props.form.setValue("saleAllocationPercentage", "0"); + props.form.setValue("salePrice", "0"); + } + }} + /> +
+ + {isEnabled && ( +
+ +
+ { + props.form.setValue("saleAllocationPercentage", value); + }} + /> + + % + +
+
+ + +
+ { + props.form.setValue("salePrice", value); + }} + /> + + {selectedChainMeta?.nativeCurrency.symbol || "ETH"} + +
+
+
+ )} +
+
+ ); +} + +function DecimalInput(props: { + value: string; + onChange: (value: string) => void; + maxValue?: number; +}) { + return ( + { + const number = Number(e.target.value); + // ignore if string becomes invalid number + if (Number.isNaN(number)) { + return; + } + + if (props.maxValue && number > props.maxValue) { + return; + } + + // replace leading multiple zeros with single zero + let cleanedValue = e.target.value.replace(/^0+/, "0"); + + // replace leading zero before decimal point + if (!cleanedValue.includes(".")) { + cleanedValue = cleanedValue.replace(/^0+/, ""); + } + + props.onChange(cleanedValue || "0"); + }} + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts new file mode 100644 index 00000000000..d6f633c8c27 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts @@ -0,0 +1,94 @@ +import type { UseFormReturn } from "react-hook-form"; +import { isAddress } from "thirdweb"; +import * as z from "zod"; + +const addressSchema = z.string().refine( + (value) => { + if (isAddress(value)) { + return true; + } + return false; + }, + { + message: "Invalid address", + }, +); + +const urlSchema = z.string().url(); + +export const tokenInfoFormSchema = z.object({ + // info fieldset + name: z.string().min(1, "Name is required"), + symbol: z + .string() + .min(1, "Symbol is required") + .max(10, "Symbol must be 10 characters or less"), + chain: z.string().min(1, "Chain is required"), + description: z.string().optional(), + image: z.any().optional(), + socialUrls: z.array( + z.object({ + platform: z.string(), + url: z.string().refine( + (val) => { + if (val === "") { + return true; + } + + const url = val.startsWith("http") ? val : `https://${val}`; + return urlSchema.safeParse(url); + }, + { + message: "Invalid URL", + }, + ), + }), + ), +}); + +export const tokenDistributionFormSchema = z.object({ + supply: z.string().min(1, "Supply is required"), + saleAllocationPercentage: z.string().refine( + (value) => { + const number = Number(value); + if (Number.isNaN(number)) { + return false; + } + return number >= 0 && number <= 100; + }, + { + message: "Must be a number between 0 and 100", + }, + ), + salePrice: z.string().refine( + (value) => { + const number = Number(value); + return !Number.isNaN(number) && number >= 0; + }, + { + message: "Must be number larger than or equal to 0", + }, + ), + airdropAddresses: z.array( + z.object({ + address: addressSchema, + quantity: z.string(), + }), + ), + // UI states + airdropEnabled: z.boolean(), + saleEnabled: z.boolean(), +}); + +export type TokenDistributionForm = UseFormReturn< + z.infer +>; +export type TokenDistributionFormValues = z.infer< + typeof tokenDistributionFormSchema +>; + +export type TokenInfoFormValues = z.infer; +export type TokenInfoForm = UseFormReturn; + +export type CreateAssetFormValues = TokenInfoFormValues & + TokenDistributionFormValues; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx new file mode 100644 index 00000000000..086f40ea0c8 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx @@ -0,0 +1,336 @@ +"use client"; +import { Img } from "@/components/blocks/Img"; +import { + type MultiStepState, + MultiStepStatus, +} from "@/components/blocks/multi-step-status/multi-step-status"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { TransactionButton } from "components/buttons/TransactionButton"; +import { ChainIconClient } from "components/icons/ChainIcon"; +import { useTrack } from "hooks/analytics/useTrack"; +import { useAllChainsData } from "hooks/chains/allChains"; +import { ArrowRightIcon, ImageOffIcon, RocketIcon } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { useActiveWallet } from "thirdweb/react"; +import { StepCard } from "../create-token-card"; +import type { CreateTokenFunctions } from "../create-token-page.client"; +import { TokenDistributionBarChart } from "../distribution/token-distribution"; +import type { CreateAssetFormValues } from "../form"; +import { getLaunchTrackingData } from "../tracking"; + +export function LaunchTokenStatus(props: { + createTokenFunctions: CreateTokenFunctions; + values: CreateAssetFormValues; + onPrevious: () => void; + client: ThirdwebClient; + onLaunchSuccess: () => void; +}) { + const formValues = props.values; + const { createTokenFunctions } = props; + const [steps, setSteps] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [contractLink, setContractLink] = useState(null); + const activeWallet = useActiveWallet(); + const walletRequiresApproval = activeWallet?.id !== "inApp"; + const trackEvent = useTrack(); + + async function handleSubmitClick() { + function launchTracking(type: "attempt" | "success" | "error") { + trackEvent( + getLaunchTrackingData({ + chainId: Number(formValues.chain), + airdropEnabled: formValues.airdropEnabled, + saleEnabled: formValues.saleEnabled, + type, + }), + ); + } + + launchTracking("attempt"); + + function updateStatus(index: number, newStatus: MultiStepState["status"]) { + setSteps((prev) => { + return [ + ...prev.slice(0, index), + { ...prev[index], status: newStatus }, + ...prev.slice(index + 1), + ] as MultiStepState[]; + }); + } + + function createSequenceExecutorFn( + index: number, + executeFn: (values: CreateAssetFormValues) => Promise, + ) { + return async () => { + updateStatus(index, "pending"); + try { + await executeFn(formValues); + updateStatus(index, "completed"); + // start next one + const nextStep = initialSteps[index + 1]; + if (nextStep) { + // do not use await next step + nextStep.execute(); + } else { + launchTracking("success"); + props.onLaunchSuccess(); + } + } catch (error) { + updateStatus(index, "error"); + launchTracking("error"); + console.error(error); + } + }; + } + + const initialSteps: MultiStepState[] = [ + { + label: "Deploy contract", + status: "idle", + retryLabel: "Failed to deploy contract", + execute: createSequenceExecutorFn(0, async (values) => { + const result = await createTokenFunctions.deployContract(values); + setContractLink(`/${values.chain}/${result.contractAddress}`); + }), + }, + { + label: "Set claim conditions", + status: "idle", + retryLabel: "Failed to set claim conditions", + execute: createSequenceExecutorFn( + 1, + createTokenFunctions.setClaimConditions, + ), + }, + { + label: "Mint tokens", + status: "idle", + retryLabel: "Failed to mint tokens", + execute: createSequenceExecutorFn(2, createTokenFunctions.mintTokens), + }, + ]; + + if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) { + initialSteps.push({ + label: "Airdrop tokens", + status: "idle", + retryLabel: "Failed to airdrop tokens", + execute: createSequenceExecutorFn( + 3, + createTokenFunctions.airdropTokens, + ), + }); + } + + setSteps(initialSteps); + setIsModalOpen(true); + + // start sequence + initialSteps[0]?.execute(); + } + + const isComplete = steps.every((step) => step.status === "completed"); + const isPending = steps.some((step) => step.status === "pending"); + + return ( + + + Launch Token + + ), + }} + > + {/* Token info */} +
+ + + + +
+
+ + + + + + + + + + + +
+ + + + +
+
+ + {/* Token distribution */} +
+ +

+ {compactNumberFormatter.format(Number(formValues.supply))} +

+
+ + +
+ + + +
+ + + Status + + {walletRequiresApproval && ( + + Each step will prompt a signature request in your wallet + + )} + + + +
+ +
+ {isComplete && contractLink ? ( +
+ +
+ ) : ( +
+ )} + + +
+ +
+
+ ); +} + +const compactNumberFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 10, +}); + +function RenderFileImage(props: { + file: File | undefined; +}) { + const [objectUrl, setObjectUrl] = useState(""); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (props.file) { + const url = URL.createObjectURL(props.file); + setObjectUrl(url); + return () => URL.revokeObjectURL(url); + } else { + setObjectUrl(""); + } + }, [props.file]); + + return ( + + +
+ } + /> + ); +} + +function OverviewField(props: { + name: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+

{props.name}

+ {props.children} +
+ ); +} + +function OverviewFieldValue(props: { + value: string; +}) { + return

{props.value}

; +} + +function ChainOverview(props: { + chainId: string; + client: ThirdwebClient; +}) { + const { idToChain } = useAllChainsData(); + const chainMetadata = idToChain.get(Number(props.chainId)); + + return ( +
+ +

+ {chainMetadata?.name || `Chain ${props.chainId}`} +

+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/page.tsx new file mode 100644 index 00000000000..a743ccf8cf2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/page.tsx @@ -0,0 +1,108 @@ +import { getProject } from "@/api/projects"; +import { getTeamBySlug } from "@/api/team"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { + getAuthToken, + getAuthTokenWalletAddress, +} from "../../../../../api/lib/getAuthToken"; +import { loginRedirect } from "../../../../../login/loginRedirect"; +import { CreateTokenAssetPage } from "./create-token-page-impl"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + + const [authToken, team, project, accountAddress] = await Promise.all([ + getAuthToken(), + getTeamBySlug(params.team_slug), + getProject(params.team_slug, params.project_slug), + getAuthTokenWalletAddress(), + ]); + + if (!authToken || !accountAddress) { + loginRedirect( + `/team/${params.team_slug}/${params.project_slug}/assets/create`, + ); + } + + if (!team) { + redirect("/team"); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: team.id, + }); + + return ( +
+ +
+ +
+
+ ); +} + +function PageHeader(props: { + teamSlug: string; + projectSlug: string; +}) { + return ( +
+
+ + + + + + Assets + + + + + + Create Token + + + +
+ +
+
+

+ Create Token +

+

+ Launch an ERC-20 token for your project +

+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/token-info-fieldset.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/token-info-fieldset.tsx new file mode 100644 index 00000000000..d469668bdde --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/token-info-fieldset.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Button } from "@/components/ui/button"; +import {} from "@/components/ui/card"; +import { Form } from "@/components/ui/form"; +import { + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { ClientOnly } from "components/ClientOnly/ClientOnly"; +import { FileInput } from "components/shared/FileInput"; +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useFieldArray } from "react-hook-form"; +import type { ThirdwebClient } from "thirdweb"; +import { StepCard } from "./create-token-card"; +import type { TokenInfoForm } from "./form"; + +export function TokenInfoFieldset(props: { + client: ThirdwebClient; + onNext: () => void; + form: TokenInfoForm; +}) { + const { form } = props; + return ( +
+ + +
+ {/* left */} + + + form.setValue("image", file, { + shouldTouch: true, + }) + } + className="rounded-lg border-border bg-background transition-all duration-200 hover:border-active-border hover:bg-background" + /> + + + {/* right */} +
+ {/* name + symbol */} +
+ + + + + + + +
+ + {/* chain */} + + + { + form.setValue("chain", chain.toString()); + }} + disableChainId + /> + + + + +