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 (
+
+
+ );
+}
+
+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))}
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+ );
+}
+
+function SocialUrls(props: {
+ form: TokenInfoForm;
+}) {
+ const { form } = props;
+
+ const { fields, append, remove } = useFieldArray({
+ name: "socialUrls",
+ control: form.control,
+ });
+
+ return (
+
+
Social URLs
+
+ {fields.length > 0 && (
+
+ {fields.map((field, index) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/tracking.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/tracking.ts
new file mode 100644
index 00000000000..efae14906af
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/tracking.ts
@@ -0,0 +1,60 @@
+export function getTokenDeploymentTrackingData(
+ type: "attempt" | "success" | "error",
+ chainId: number,
+) {
+ // using "custom-contract" because it has to match the main deployment tracking format
+ return {
+ category: "custom-contract",
+ action: "deploy",
+ label: type,
+ publisherAndContractName: "deployer.thirdweb.eth/DropERC20",
+ chainId: chainId,
+ deploymentType: "asset",
+ };
+}
+
+// example: asset.claim-conditions.attempt
+export function getTokenStepTrackingData(params: {
+ action: "claim-conditions" | "airdrop" | "mint";
+ chainId: number;
+ status: "attempt" | "success" | "error";
+}) {
+ return {
+ category: "asset",
+ action: params.action,
+ contractType: "DropERC20",
+ label: params.status,
+ chainId: params.chainId,
+ };
+}
+
+// example: asset.launch.attempt
+export function getLaunchTrackingData(params: {
+ chainId: number;
+ airdropEnabled: boolean;
+ saleEnabled: boolean;
+ type: "attempt" | "success" | "error";
+}) {
+ return {
+ category: "asset",
+ action: "launch",
+ label: params.type,
+ contractType: "DropERC20",
+ chainId: params.chainId,
+ airdropEnabled: params.airdropEnabled,
+ saleEnabled: params.saleEnabled,
+ };
+}
+
+// example: asset.info-page.next-click
+export function getStepCardTrackingData(params: {
+ step: "info" | "distribution" | "launch";
+ click: "prev" | "next";
+}) {
+ return {
+ category: "asset",
+ action: `${params.step}-page`,
+ label: `${params.click}-click`,
+ contractType: "DropERC20",
+ };
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/page.tsx
new file mode 100644
index 00000000000..bacd15ba300
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/page.tsx
@@ -0,0 +1,115 @@
+import { getProject } from "@/api/projects";
+import { getTeamBySlug } from "@/api/team";
+import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
+import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
+import { redirect } from "next/navigation";
+import { Suspense } from "react";
+import type { ThirdwebClient } from "thirdweb";
+import { ClientOnly } from "../../../../../../components/ClientOnly/ClientOnly";
+import { ContractTable } from "../../../../../../components/contract-components/tables/contract-table";
+import { getSortedDeployedContracts } from "../../../../account/contracts/_components/getSortedDeployedContracts";
+import { getAuthToken } from "../../../../api/lib/getAuthToken";
+import { loginRedirect } from "../../../../login/loginRedirect";
+import { Cards } from "./cards";
+
+export default async function Page(props: {
+ params: Promise<{ team_slug: string; project_slug: string }>;
+}) {
+ const params = await props.params;
+
+ const [authToken, team, project] = await Promise.all([
+ getAuthToken(),
+ getTeamBySlug(params.team_slug),
+ getProject(params.team_slug, params.project_slug),
+ ]);
+
+ if (!authToken) {
+ loginRedirect(`/team/${params.team_slug}/${params.project_slug}/assets`);
+ }
+
+ if (!team) {
+ redirect("/team");
+ }
+
+ if (!project) {
+ redirect(`/team/${params.team_slug}`);
+ }
+
+ const client = getClientThirdwebClient({
+ jwt: authToken,
+ teamId: team.id,
+ });
+
+ return (
+
+
+
+
+
+
+
Your assets
+
+ List of all assets created or imported into this project
+
+
+
+
}>
+
+
+
+
+ );
+}
+
+function AssetsHeader() {
+ return (
+
+
+
+ Assets
+
+
+ Create and Manage tokens for your project
+
+
+
+ );
+}
+
+async function AssetsPageAsync(props: {
+ teamId: string;
+ projectId: string;
+ authToken: string;
+ client: ThirdwebClient;
+}) {
+ const deployedContracts = await getSortedDeployedContracts({
+ teamId: props.teamId,
+ projectId: props.projectId,
+ authToken: props.authToken,
+ deploymentType: "asset",
+ });
+
+ return (
+
}>
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/components/ProjectSidebarLayout.tsx
index 861d72d1f6e..46cd293e3be 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/components/ProjectSidebarLayout.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/components/ProjectSidebarLayout.tsx
@@ -3,6 +3,7 @@ import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
import {
BookTextIcon,
BoxIcon,
+ CoinsIcon,
HomeIcon,
SettingsIcon,
WalletIcon,
@@ -52,11 +53,7 @@ export function ProjectSidebarLayout(props: {
{
href: `${layoutPath}/connect/universal-bridge`,
icon: PayIcon,
- label: (
-
- Universal Bridge New
-
- ),
+ label: "Universal Bridge",
tracking: tracking("universal-bridge"),
},
{
@@ -65,6 +62,16 @@ export function ProjectSidebarLayout(props: {
icon: ContractIcon,
tracking: tracking("contracts"),
},
+ {
+ href: `${layoutPath}/assets`,
+ label: (
+
+ Assets New
+
+ ),
+ icon: CoinsIcon,
+ tracking: tracking("assets"),
+ },
{
href: `${layoutPath}/engine`,
label: (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/account-abstraction/factories/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/account-abstraction/factories/page.tsx
index c65fe41b71f..eb3ddf96ec5 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/account-abstraction/factories/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/account-abstraction/factories/page.tsx
@@ -113,6 +113,7 @@ async function AsyncYourFactories(props: {
teamId: props.teamId,
projectId: props.projectId,
authToken: props.authToken,
+ deploymentType: undefined,
});
const factories = (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/hooks/project-contracts.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/hooks/project-contracts.ts
index 752aaea8ac5..fceb0086cca 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/hooks/project-contracts.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/hooks/project-contracts.ts
@@ -10,6 +10,8 @@ export function useAddContractToProject() {
projectId: string;
contractAddress: string;
chainId: string;
+ deploymentType: "asset" | undefined;
+ contractType: "DropERC20" | undefined;
}) => {
const res = await apiServerProxy({
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/contracts`,
@@ -17,6 +19,8 @@ export function useAddContractToProject() {
body: JSON.stringify({
contractAddress: params.contractAddress,
chainId: params.chainId,
+ deploymentType: params.deploymentType,
+ contractType: params.contractType,
}),
headers: {
"Content-Type": "application/json",
diff --git a/apps/dashboard/src/components/buttons/MismatchButton.tsx b/apps/dashboard/src/components/buttons/MismatchButton.tsx
index 92e3ad70d29..ccd6e65976d 100644
--- a/apps/dashboard/src/components/buttons/MismatchButton.tsx
+++ b/apps/dashboard/src/components/buttons/MismatchButton.tsx
@@ -245,15 +245,15 @@ export const MismatchButton = forwardRef<
{dialog === "no-funds" && (
{
trackEvent({
category: "pay",
@@ -305,7 +305,8 @@ export const MismatchButton = forwardRef<
}
},
prefillBuy: {
- chain: activeWalletChain,
+ amount: "0.01",
+ chain: txChain,
},
}}
/>
diff --git a/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx b/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx
index 601a1227be7..8523571ef9f 100644
--- a/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx
+++ b/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx
@@ -687,6 +687,8 @@ export const CustomContractForm: React.FC = ({
contractAddress: contractAddr,
projectId: importSelection.project.id,
teamId: importSelection.team.id,
+ deploymentType: undefined,
+ contractType: undefined,
});
}
} catch (e) {
@@ -694,8 +696,8 @@ export const CustomContractForm: React.FC = ({
console.error("failed to deploy contract", e);
trackEvent({
category: "custom-contract",
- action: "error",
- label: "success",
+ action: "deploy",
+ label: "error",
...publisherAnalyticsData,
chainId: walletChain.id,
metadataUri: metadata.metadataUri,
diff --git a/apps/dashboard/src/components/contract-components/import-contract/modal.tsx b/apps/dashboard/src/components/contract-components/import-contract/modal.tsx
index 8517a977f06..c996fcde988 100644
--- a/apps/dashboard/src/components/contract-components/import-contract/modal.tsx
+++ b/apps/dashboard/src/components/contract-components/import-contract/modal.tsx
@@ -23,7 +23,7 @@ import { Label } from "@/components/ui/label";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { zodResolver } from "@hookform/resolvers/zod";
import { useChainSlug } from "hooks/chains/chainSlug";
-import { ExternalLinkIcon, PlusIcon } from "lucide-react";
+import { ArrowDownToLineIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -38,6 +38,7 @@ type ImportModalProps = {
teamId: string;
projectId: string;
client: ThirdwebClient;
+ type: "contract" | "asset";
};
export const ImportModal: React.FC = (props) => {
@@ -50,14 +51,16 @@ export const ImportModal: React.FC = (props) => {
}
}}
>
-
-
+
+
- Import Contract
+ Import {props.type === "contract" ? "Contract" : "Asset"}
- Import an already deployed contract into thirdweb by entering a
- contract address below.
+ Import a deployed contract in your project
@@ -65,6 +68,7 @@ export const ImportModal: React.FC = (props) => {
teamId={props.teamId}
projectId={props.projectId}
client={props.client}
+ type={props.type}
/>
@@ -91,6 +95,7 @@ function ImportForm(props: {
teamId: string;
projectId: string;
client: ThirdwebClient;
+ type: "contract" | "asset";
}) {
const router = useDashboardRouter();
const activeChainId = useActiveWalletChain()?.id;
@@ -150,6 +155,8 @@ function ImportForm(props: {
chainId: chainId.toString(),
teamId: props.teamId,
projectId: props.projectId,
+ deploymentType: props.type === "contract" ? undefined : "asset",
+ contractType: undefined,
},
{
onSuccess: () => {
@@ -172,34 +179,35 @@ function ImportForm(props: {
}
})}
>
- (
-
- Contract Address
-
-
-
-
-
- )}
- />
-
-
-
-
-
form.setValue("chainId", v)}
- side="top"
+
+ (
+
+ Contract Address
+
+
+
+
+
+ )}
/>
-
-
+
+
+
+ form.setValue("chainId", v)}
+ side="top"
+ disableChainId
+ />
+
+
-
+
{addContractToProject.isSuccess &&
addContractToProject.data?.result ? (
)}
diff --git a/apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx b/apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx
index 60b13919444..66c16310bd3 100644
--- a/apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx
+++ b/apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx
@@ -59,6 +59,7 @@ function Story() {
Promise;
client: ThirdwebClient;
+ variant: "asset" | "contract";
}) {
// instantly update the table without waiting for router refresh by adding deleted contract ids to the state
const [deletedContractIds, setDeletedContractIds] = useState([]);
@@ -213,13 +216,19 @@ export function ContractTableUI(props: {
{contracts.length === 0 && (
-
No contracts added to project
-
+ {props.variant === "asset" ? (
+
No assets found
+ ) : (
+
No contracts found
+ )}
+ {props.variant === "contract" && (
+
+ )}
)}
@@ -331,7 +340,7 @@ function RemoveContractButton(props: {
onContractRemoved?: () => void;
contractId: string;
}) {
- const removeMuation = useMutation({
+ const removeMutation = useMutation({
mutationFn: props.removeContractFromProject,
});
@@ -340,7 +349,7 @@ function RemoveContractButton(props: {
variant="ghost"
onClick={(e) => {
e.stopPropagation();
- removeMuation.mutateAsync(props.contractId, {
+ removeMutation.mutateAsync(props.contractId, {
onSuccess: () => {
props.onContractRemoved?.();
toast.success("Contract removed successfully");
@@ -350,10 +359,10 @@ function RemoveContractButton(props: {
},
});
}}
- disabled={removeMuation.isPending}
+ disabled={removeMutation.isPending}
className="justify-start gap-2"
>
- {removeMuation.isPending ? (
+ {removeMutation.isPending ? (
) : (
diff --git a/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx b/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx
index c47fe34d5e0..23b2a399097 100644
--- a/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx
+++ b/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx
@@ -1,7 +1,7 @@
"use client";
+import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Input } from "@/components/ui/input";
-import { Box, Spinner } from "@chakra-ui/react";
import { useEns } from "components/contract-components/hooks";
import { CheckIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -108,7 +108,7 @@ export const SolidityAddressInput: React.FC = ({
}, [inputNameWatch, localInput, address]);
const helperTextLeft = resolvingEns ? (
-
+
) : resolvedAddress || ensFound ? (
) : null;
@@ -116,13 +116,9 @@ export const SolidityAddressInput: React.FC = ({
const helperTextRight = resolvingEns ? (
"Resolving ENS..."
) : resolvedAddress ? (
-
- {ensQuery?.data?.address}
-
+ {ensQuery?.data?.address}
) : ensFound ? (
-
- {ensQuery?.data?.ensName}
-
+ {ensQuery?.data?.ensName}
) : null;
return (
@@ -138,12 +134,10 @@ export const SolidityAddressInput: React.FC = ({
/>
{!hasError && (helperTextLeft || helperTextRight) && (
-
-
- {helperTextLeft}
- {helperTextRight}
-
-
+
+ {helperTextLeft}
+ {helperTextRight}
+
)}
>
);
diff --git a/apps/dashboard/src/global.css b/apps/dashboard/src/global.css
index 2c748df498c..6893d5294a7 100644
--- a/apps/dashboard/src/global.css
+++ b/apps/dashboard/src/global.css
@@ -207,3 +207,11 @@ body {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted)) transparent;
}
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ margin: 0;
+}