From 73629e9f0e422570eea8d040b0ad5d20462b71b5 Mon Sep 17 00:00:00 2001 From: MananTank Date: Thu, 22 May 2025 23:15:23 +0000 Subject: [PATCH] [TOOL-4531] Dashboard: Add Token Asset creation wizard (#7081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR introduces several enhancements and features related to asset management, including deployment tracking, UI updates, and new forms for creating and managing tokens. It also refines existing components and adds new functionalities for improved user experience. ### Detailed summary - Added `deploymentType` and `contractType` properties in various components. - Introduced `revalidatePathAction` for path revalidation. - Updated UI components for better asset management. - Enhanced forms for token creation with validation. - Implemented multi-step status tracking for token launch processes. - Improved error handling and user feedback in forms. - Added new charts for token distribution visualization. > The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-airdrop.tsx` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit - **New Features** - Introduced a comprehensive asset management interface for creating, importing, and managing ERC-20 tokens within team and project dashboards. - Added a multi-step token creation flow with form validation, including token info, distribution, sale settings, airdrop CSV upload, and launch process with real-time status tracking. - Implemented visual token allocation charts and multi-step progress indicators for enhanced user experience. - Added a dedicated "Assets" section in the sidebar with a "New" badge. - Added a horizontal segmented distribution bar chart component for visualizing token allocations. - Introduced a multi-step status component to track asynchronous step executions with retry capabilities. - Added token airdrop CSV upload and validation feature with user-friendly UI and error handling. - Added token sale configuration section with price and allocation inputs. - Enhanced import modal to support both contract and asset imports with contextual UI and behavior. - Added new pages and components to support token asset creation and management workflows, including cards for asset actions. - Added server-side path revalidation support for pages and layouts. - **Improvements** - Enhanced contract and asset import workflows with clearer UI, context-aware dialogs, and improved contract table variant handling. - Updated number input fields to remove browser-native spinners for a cleaner appearance. - Improved analytics event tracking for user interactions and deployment steps. - Refined UI components for consistent styling and accessibility. - Fixed sidebar badge placement and updated UI text for clarity. - Updated dialogs and payment embeds to reference transaction chain instead of active wallet chain and set default payment amounts. - Changed token supply label to "Circulating Supply" for clarity. - Replaced Chakra UI spinner and styled elements with local components for simplified UI. - **Bug Fixes** - Corrected UI typos and improved analytics event tracking for deployment errors. - **Documentation** - Added Storybook stories for new components to facilitate UI testing and documentation. - **Chores** - Refactored and extended types and props to support new asset and contract features, ensuring future extensibility. - Added tracking utilities for token deployment and asset creation analytics. --- apps/dashboard/src/@/actions/revalidate.ts | 10 + .../components/blocks/distribution-chart.tsx | 71 +++ .../multi-step-status.stories.tsx | 66 +++ .../multi-step-status/multi-step-status.tsx | 72 +++ .../claim-conditions-form/index.tsx | 2 + .../_layout/primary-dashboard-button.tsx | 2 + .../tokens/components/supply-layout.tsx | 2 +- .../contracts/DeployedContractsPageHeader.tsx | 1 + .../_components/DeployViaCLIOrImportCard.tsx | 1 + .../_components/DeployedContractsPage.tsx | 2 + .../_components/getProjectContracts.ts | 19 +- .../getSortedDeployedContracts.tsx | 2 + .../[project_slug]/assets/cards.tsx | 135 ++++++ .../assets/create/create-token-card.tsx | 79 ++++ .../assets/create/create-token-page-impl.tsx | 362 +++++++++++++++ .../create/create-token-page.client.tsx | 119 +++++ .../create/create-token-page.stories.tsx | 63 +++ .../create/distribution/token-airdrop.tsx | 437 ++++++++++++++++++ .../distribution/token-distribution.tsx | 110 +++++ .../assets/create/distribution/token-sale.tsx | 126 +++++ .../[project_slug]/assets/create/form.ts | 94 ++++ .../assets/create/launch/launch-token.tsx | 336 ++++++++++++++ .../[project_slug]/assets/create/page.tsx | 108 +++++ .../assets/create/token-info-fieldset.tsx | 220 +++++++++ .../[project_slug]/assets/create/tracking.ts | 60 +++ .../[project_slug]/assets/page.tsx | 115 +++++ .../components/ProjectSidebarLayout.tsx | 17 +- .../account-abstraction/factories/page.tsx | 1 + .../[project_slug]/hooks/project-contracts.ts | 4 + .../src/components/buttons/MismatchButton.tsx | 9 +- .../contract-deploy-form/custom-contract.tsx | 6 +- .../import-contract/modal.tsx | 76 +-- .../tables/contract-table.stories.tsx | 7 + .../tables/contract-table.tsx | 31 +- .../solidity-inputs/address-input.tsx | 22 +- apps/dashboard/src/global.css | 8 + 36 files changed, 2717 insertions(+), 78 deletions(-) create mode 100644 apps/dashboard/src/@/actions/revalidate.ts create mode 100644 apps/dashboard/src/@/components/blocks/distribution-chart.tsx create mode 100644 apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx create mode 100644 apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/cards.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-card.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-airdrop.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-distribution.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/token-info-fieldset.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/tracking.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/page.tsx 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 + /> + + + + +