@@ -64,7 +75,7 @@ export function MultiStepStatus(props: {
variant="destructive"
size="sm"
className="gap-2"
- onClick={() => step.execute()}
+ onClick={() => props.onRetry(step)}
>
Retry
diff --git a/apps/dashboard/src/@/components/ui/decimal-input.tsx b/apps/dashboard/src/@/components/ui/decimal-input.tsx
index ee942675c15..ed3b7be6591 100644
--- a/apps/dashboard/src/@/components/ui/decimal-input.tsx
+++ b/apps/dashboard/src/@/components/ui/decimal-input.tsx
@@ -6,6 +6,7 @@ export function DecimalInput(props: {
id?: string;
className?: string;
placeholder?: string;
+ disabled?: boolean;
}) {
return (
{
const number = Number(e.target.value);
// ignore if string becomes invalid number
diff --git a/apps/dashboard/src/@/lib/file-to-url.ts b/apps/dashboard/src/@/lib/file-to-url.ts
new file mode 100644
index 00000000000..8c08065cba6
--- /dev/null
+++ b/apps/dashboard/src/@/lib/file-to-url.ts
@@ -0,0 +1,4 @@
+export function fileToBlobUrl(file: File) {
+ const blob = new Blob([file], { type: file.type });
+ return URL.createObjectURL(blob);
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/page.tsx
index a0d78cfe929..6b9345594e6 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/page.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/page.tsx
@@ -1,7 +1,7 @@
+import { getAuthToken } from "@app/api/lib/getAuthToken";
import { ArrowUpRightIcon } from "lucide-react";
import type { Metadata } from "next";
import { headers } from "next/headers";
-import { getAuthToken } from "../../../api/lib/getAuthToken";
import { SearchInput } from "./components/client/search";
import { QueryType } from "./components/client/type";
import { RouteListView } from "./components/client/view";
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx
index 9a6175bf37d..5a0355cc569 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx
@@ -49,6 +49,7 @@ import {
useSwitchActiveWalletChain,
useWalletBalance,
} from "thirdweb/react";
+import { parseError } from "utils/errorParser";
import { z } from "zod";
function formatTime(seconds: number) {
@@ -234,7 +235,12 @@ export function FaucetButton({
const claimPromise = claimMutation.mutateAsync(values.turnstileToken);
toast.promise(claimPromise, {
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
- error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
+ error: (err) => {
+ return {
+ message: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
+ description: parseError(err),
+ };
+ },
});
};
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/layout.tsx
index 5c654e0d9b7..21a7d2b0f43 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/layout.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/layout.tsx
@@ -14,16 +14,16 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
+import {
+ getAuthToken,
+ getAuthTokenWalletAddress,
+} from "@app/api/lib/getAuthToken";
import { ChevronDownIcon, TicketCheckIcon } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { mapV4ChainToV5Chain } from "../../../../../../contexts/map-chains";
import { NebulaChatButton } from "../../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
-import {
- getAuthToken,
- getAuthTokenWalletAddress,
-} from "../../../../api/lib/getAuthToken";
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
import { StarButton } from "../../components/client/star-button";
import { getChain, getChainMetadata } from "../../utils";
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/snapshot-upload.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/snapshot-upload.tsx
index 55cb315ea80..49a7b6ab908 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/snapshot-upload.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/snapshot-upload.tsx
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
import { useCsvUpload } from "hooks/useCsvUpload";
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
import { useRef } from "react";
+import { useDropzone } from "react-dropzone";
import type { Column } from "react-table";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { CsvDataTable } from "../csv-data-table";
@@ -52,23 +53,18 @@ const SnapshotViewerSheetContent: React.FC = ({
onClose,
client,
}) => {
- const {
- normalizeQuery,
- getInputProps,
- getRootProps,
- isDragActive,
- rawData,
- noCsv,
- reset,
- removeInvalid,
- } = useCsvUpload({
+ const csvUpload = useCsvUpload({
csvParser,
defaultRawData: value,
client,
});
+ const dropzone = useDropzone({
+ onDrop: csvUpload.setFiles,
+ });
+
const paginationPortalRef = useRef(null);
- const normalizeData = normalizeQuery.data;
+ const normalizeData = csvUpload.normalizeQuery.data;
if (!normalizeData) {
return (
@@ -152,11 +148,11 @@ const SnapshotViewerSheetContent: React.FC = ({
return (
- {rawData.length > 0 ? (
+ {csvUpload.rawData.length > 0 ? (
portalRef={paginationPortalRef}
- data={normalizeQuery.data.result}
+ data={csvUpload.normalizeQuery.data.result}
columns={columns}
/>
@@ -166,23 +162,27 @@ const SnapshotViewerSheetContent: React.FC
= ({
-
+
- {isDragActive ? (
+ {dropzone.isDragActive ? (
Drop the files here
) : (
-
- {noCsv
+
+ {csvUpload.noCsv
? `No valid CSV file found, make sure your CSV includes the "address" column.`
: "Drag & Drop a CSV file here"}
@@ -294,20 +294,20 @@ const SnapshotViewerSheetContent: React.FC
= ({
- {normalizeQuery.data?.invalidFound ? (
+ {csvUpload.normalizeQuery.data?.invalidFound ? (
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx
index cf1753a6f2a..5f75485f9b6 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx
@@ -1,5 +1,5 @@
+import { getAuthToken } from "@app/api/lib/getAuthToken";
import type { Metadata } from "next";
-import { getAuthToken } from "../../../../api/lib/getAuthToken";
import {
SharedContractLayout,
generateContractLayoutMetadata,
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/BatchMetadata.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/BatchMetadata.tsx
index 621b03ce0e6..4402b162f59 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/BatchMetadata.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/BatchMetadata.tsx
@@ -199,7 +199,11 @@ function UploadMetadataNFTSection(props: {
{/* Left */}
-
+
{/* Right */}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx
index a2d9b40b26c..3b5d812b91b 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx
@@ -397,7 +397,11 @@ function MintNFTSection(props: {
{/* Left */}
-
+
{/* Right */}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/NFTMediaFormGroup.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/NFTMediaFormGroup.tsx
index a9a5a1bc7f2..9b4d4ac9c24 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/NFTMediaFormGroup.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/NFTMediaFormGroup.tsx
@@ -1,89 +1,37 @@
"use client";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { FileInput } from "components/shared/FileInput";
-import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import type { UseFormReturn } from "react-hook-form";
+import type { ThirdwebClient } from "thirdweb";
import type { NFTInput } from "thirdweb/utils";
+import {
+ getUploadedNFTMediaMeta,
+ handleNFTMediaUpload,
+} from "./handleNFTMediaUpload";
type NFTMediaFormGroupValues = {
image?: NFTInput["image"];
animation_url?: NFTInput["animation_url"];
external_url?: NFTInput["external_url"];
};
+
export function NFTMediaFormGroup
(props: {
form: UseFormReturn;
previewMaxWidth?: string;
+ client: ThirdwebClient;
}) {
// T contains all properties of NFTMediaFormGroupValues, so this is a-ok
const form = props.form as unknown as UseFormReturn;
- const setFile = (file: File) => {
- const external_url = form.watch("external_url");
- const animation_url = form.watch("animation_url");
-
- if (file.type.includes("image")) {
- form.setValue("image", file);
- if (external_url instanceof File) {
- form.setValue("external_url", undefined);
- }
- if (animation_url instanceof File) {
- form.setValue("animation_url", undefined);
- }
- } else if (
- ["audio", "video", "text/html", "model/*"].some((type: string) =>
- file.type.includes(type),
- ) ||
- file.name?.endsWith(".glb") ||
- file.name?.endsWith(".usdz") ||
- file.name?.endsWith(".gltf") ||
- file.name.endsWith(".obj")
- ) {
- // audio, video, html, and glb (3d) files
- form.setValue("animation_url", file);
- if (external_url instanceof File) {
- form.setValue("external_url", undefined);
- }
- } else if (
- ["text", "application/pdf"].some((type: string) =>
- file.type?.includes(type),
- )
- ) {
- // text and pdf files
- form.setValue("external_url", file);
- if (animation_url instanceof File) {
- form.setValue("animation_url", undefined);
- }
- }
- };
-
- const external_url = form.watch("external_url");
- const animation_url = form.watch("animation_url");
- const image = form.watch("image");
- const errors = form.formState.errors;
+ const { media, image, mediaFileError, showCoverImageUpload } =
+ getUploadedNFTMediaMeta(form);
- const imageUrl = useImageFileOrUrl(image as File | string);
- const showCoverImageUpload =
- animation_url instanceof File || external_url instanceof File;
-
- const mediaFileUrl =
- animation_url instanceof File
- ? animation_url
- : external_url instanceof File
- ? external_url
- : image instanceof File
- ? imageUrl
- : undefined;
+ const previewMaxWidth = props.previewMaxWidth ?? "200px";
- const mediaFileError =
- animation_url instanceof File
- ? errors?.animation_url
- : external_url instanceof File
- ? errors?.external_url
- : image instanceof File
- ? errors?.image
- : undefined;
+ const setFile = (file: File) => {
+ handleNFTMediaUpload({ file, form });
+ };
- const previewMaxWidth = props.previewMaxWidth ?? "200px";
return (
(props: {
@@ -112,12 +61,13 @@ export function NFTMediaFormGroup
(props: {
htmlFor="cover-image"
label="Cover Image"
tooltip="You can optionally upload an image as the cover of your NFT."
- isRequired
+ isRequired={false}
>
{
form.setValue("image", file);
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/handleNFTMediaUpload.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/handleNFTMediaUpload.ts
new file mode 100644
index 00000000000..774de5b24d0
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/handleNFTMediaUpload.ts
@@ -0,0 +1,85 @@
+import type { UseFormReturn } from "react-hook-form";
+import type { NFTInput } from "thirdweb/utils";
+
+type MinimalNFTMetadata = {
+ image?: NFTInput["image"];
+ animation_url?: NFTInput["animation_url"];
+ external_url?: NFTInput["external_url"];
+};
+
+export function handleNFTMediaUpload(params: {
+ file: File;
+ form: UseFormReturn;
+}) {
+ const { file } = params;
+ const form = params.form as unknown as UseFormReturn;
+
+ const external_url = form.getValues("external_url");
+ const animation_url = form.getValues("animation_url");
+
+ if (file.type.includes("image")) {
+ form.setValue("image", file);
+
+ if (external_url instanceof File) {
+ form.setValue("external_url", undefined);
+ }
+
+ if (animation_url instanceof File) {
+ form.setValue("animation_url", undefined);
+ }
+ } else if (
+ ["audio", "video", "text/html", "model/"].some((type: string) =>
+ file.type.includes(type),
+ ) ||
+ file.name?.endsWith(".glb") ||
+ file.name?.endsWith(".usdz") ||
+ file.name?.endsWith(".gltf") ||
+ file.name.endsWith(".obj")
+ ) {
+ // audio, video, html, and glb (3d) files
+ form.setValue("animation_url", file);
+ if (external_url instanceof File) {
+ form.setValue("external_url", undefined);
+ }
+ } else if (
+ ["text", "application/pdf"].some((type: string) =>
+ file.type?.includes(type),
+ )
+ ) {
+ // text and pdf files
+ form.setValue("external_url", file);
+ if (animation_url instanceof File) {
+ form.setValue("animation_url", undefined);
+ }
+ }
+}
+
+export function getUploadedNFTMediaMeta(
+ _form: UseFormReturn,
+) {
+ const form = _form as unknown as UseFormReturn;
+
+ const _animation_url = form.watch("animation_url");
+ const _external_url = form.watch("external_url");
+ const _image = form.watch("image");
+ const _media = _animation_url || _external_url || _image;
+ const errors = form.formState.errors;
+
+ return {
+ media: stringOrFile(_media),
+ image: stringOrFile(_image),
+ mediaFileError:
+ errors?.animation_url || errors.external_url || errors?.image,
+ showCoverImageUpload: !!_animation_url || !!_external_url,
+ animation_url: stringOrFile(_animation_url),
+ external_url: stringOrFile(_external_url),
+ };
+}
+
+function stringOrFile(value: unknown): string | File | undefined {
+ if (typeof value === "string" || value instanceof File) {
+ return value;
+ }
+
+ return undefined;
+}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/claim-tab.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/claim-tab.tsx
index a3e6da385a7..db79fcd8e42 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/claim-tab.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/claim-tab.tsx
@@ -10,6 +10,7 @@ import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
import { claimTo } from "thirdweb/extensions/erc1155";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
+import { parseError } from "utils/errorParser";
interface ClaimTabProps {
contract: ThirdwebContract;
@@ -67,7 +68,12 @@ const ClaimTabERC1155: React.FC = ({
toast.promise(promise, {
loading: "Claiming NFT",
success: "NFT claimed successfully",
- error: "Failed to claim NFT",
+ error: (error) => {
+ return {
+ message: "Failed to claim NFT",
+ description: parseError(error),
+ };
+ },
});
trackEvent({
category: "nft",
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/update-metadata-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/update-metadata-form.tsx
index 1022a69b8ff..2c8ca2b5d3c 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/update-metadata-form.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/update-metadata-form.tsx
@@ -15,7 +15,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
-import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import { type Dispatch, type SetStateAction, useMemo } from "react";
import { useForm } from "react-hook-form";
@@ -38,9 +37,12 @@ import {
FormLabel,
Heading,
} from "tw-components";
-import { NFTMediaWithEmptyState } from "tw-components/nft-media";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
+import {
+ getUploadedNFTMediaMeta,
+ handleNFTMediaUpload,
+} from "../../../modules/components/nft/handleNFTMediaUpload";
const UPDATE_METADATA_FORM_ID = "nft-update-metadata-form";
@@ -83,6 +85,11 @@ export const UpdateNftMetadata: React.FC = ({
return nftMetadata;
}, [nft]);
+ const form = useForm({
+ defaultValues: transformedQueryData,
+ values: transformedQueryData,
+ });
+
const {
setValue,
control,
@@ -90,78 +97,20 @@ export const UpdateNftMetadata: React.FC = ({
watch,
handleSubmit,
formState: { errors, isDirty },
- } = useForm({
- defaultValues: transformedQueryData,
- values: transformedQueryData,
- });
+ } = form;
const setFile = (file: File) => {
- if (file.type.includes("image")) {
- // image files
- setValue("image", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- } else if (
- ["audio", "video", "text/html", "model/*"].some((type: string) =>
- file.type.includes(type),
- ) ||
- file.name?.endsWith(".glb") ||
- file.name?.endsWith(".usdz") ||
- file.name?.endsWith(".gltf") ||
- file.name.endsWith(".obj")
- ) {
- // audio, video, html, and glb (3d) files
- setValue("animation_url", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- } else if (
- ["text", "application/pdf"].some((type: string) =>
- file.type?.includes(type),
- )
- ) {
- // text and pdf files
- setValue("external_url", file);
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- }
+ handleNFTMediaUpload({ file, form });
};
+ const {
+ media,
+ image,
+ mediaFileError,
+ showCoverImageUpload,
+ animation_url,
+ external_url,
+ } = getUploadedNFTMediaMeta(form);
- const imageUrl = useImageFileOrUrl(watch("image") as File | string);
- const animationUrlFormValue = watch("animation_url");
- const imageUrlFormValue = watch("image");
-
- const mediaFileUrl =
- watch("animation_url") instanceof File
- ? watch("animation_url")
- : watch("external_url") instanceof File
- ? watch("external_url")
- : watch("image") instanceof File
- ? imageUrl
- : undefined;
-
- const mediaFileError =
- watch("animation_url") instanceof File
- ? errors?.animation_url
- : watch("external_url") instanceof File
- ? errors?.external_url
- : watch("image") instanceof File
- ? errors?.image
- : undefined;
-
- const externalUrl = watch("external_url");
- const externalIsTextFile =
- externalUrl instanceof File &&
- (externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
-
- const showCoverImageUpload =
- watch("animation_url") instanceof File ||
- watch("external_url") instanceof File;
const sendAndConfirmTx = useSendAndConfirmTransaction();
const updateMetadataNotifications = useTxNotifications(
"NFT metadata updated successfully",
@@ -247,37 +196,20 @@ export const UpdateNftMetadata: React.FC = ({
{errors?.name?.message}
+
Media
- {nft?.metadata && !mediaFileUrl && (
-
- )}
-
@@ -289,13 +221,15 @@ export const UpdateNftMetadata: React.FC = ({
{mediaFileError?.message as unknown as string}
+
{showCoverImageUpload && (
Cover Image
setValue("image", file)}
className="rounded border border-border transition-all"
@@ -320,6 +254,7 @@ export const UpdateNftMetadata: React.FC = ({
control={control}
register={register}
setValue={setValue}
+ client={contract.client}
/>
= ({
{errors?.background_color?.message}
- {!externalIsTextFile && (
+
+ {!(external_url instanceof File) && (
External URL
@@ -360,42 +296,43 @@ export const UpdateNftMetadata: React.FC = ({
)}
-
- Image URL
- {
- setValue("image", e.target.value);
- }}
- />
-
- If you already have your NFT image pre-uploaded to a URL, you
- can specify it here instead of uploading the media file
-
- {errors?.image?.message}
-
-
- Animation URL
- {
- setValue("animation_url", e.target.value);
- }}
- />
-
- If you already have your NFT Animation URL pre-uploaded to a
- URL, you can specify it here instead of uploading the media file
-
-
- {errors?.animation_url?.message}
-
-
+
+ {!(image instanceof File) && (
+
+ Image URL
+ {
+ setValue("image", e.target.value);
+ }}
+ />
+
+ If you already have your NFT image pre-uploaded to a URL, you
+ can specify it here instead of uploading the media file
+
+ {errors?.image?.message}
+
+ )}
+
+ {!(animation_url instanceof File) && (
+
+ Animation URL
+ {
+ setValue("animation_url", e.target.value);
+ }}
+ />
+
+ If you already have your NFT Animation URL pre-uploaded to a
+ URL, you can specify it here instead of uploading the media
+ file
+
+
+ {errors?.animation_url?.message}
+
+
+ )}
@@ -415,7 +352,7 @@ export const UpdateNftMetadata: React.FC = ({
isPending={sendAndConfirmTx.isPending}
form={UPDATE_METADATA_FORM_ID}
type="submit"
- disabled={!isDirty && imageUrl === nft?.metadata.image}
+ disabled={!isDirty}
>
Update NFT
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx
index abb33dbd286..851efebf5e3 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx
@@ -17,7 +17,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
-import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
@@ -34,6 +33,10 @@ import {
} from "tw-components";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
+import {
+ getUploadedNFTMediaMeta,
+ handleNFTMediaUpload,
+} from "../../modules/components/nft/handleNFTMediaUpload";
const LAZY_MINT_FORM_ID = "nft-lazy-mint-form";
@@ -54,6 +57,8 @@ export const LazyMintNftForm: React.FC = ({
const address = useActiveAccount()?.address;
const sendAndConfirmTx = useSendAndConfirmTransaction();
+ const form = useForm();
+
const {
setValue,
control,
@@ -61,75 +66,20 @@ export const LazyMintNftForm: React.FC = ({
watch,
handleSubmit,
formState: { errors, isDirty },
- } = useForm();
+ } = form;
const setFile = (file: File) => {
- if (file.type.includes("image")) {
- // image files
- setValue("image", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- } else if (
- ["audio", "video", "text/html", "model/*"].some((type: string) =>
- file.type.includes(type),
- ) ||
- file.name?.endsWith(".glb") ||
- file.name?.endsWith(".usdz") ||
- file.name?.endsWith(".gltf") ||
- file.name.endsWith(".obj")
- ) {
- // audio, video, html, and glb (3d) files
- setValue("animation_url", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- } else if (
- ["text", "application/pdf"].some((type: string) =>
- file.type?.includes(type),
- )
- ) {
- // text and pdf files
- setValue("external_url", file);
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- }
+ handleNFTMediaUpload({ file, form });
};
- const imageUrl = useImageFileOrUrl(watch("image") as File | string);
- const animationUrlFormValue = watch("animation_url");
- const imageUrlFormValue = watch("image");
-
- const mediaFileUrl =
- watch("animation_url") instanceof File
- ? watch("animation_url")
- : watch("external_url") instanceof File
- ? watch("external_url")
- : watch("image") instanceof File
- ? imageUrl
- : undefined;
-
- const mediaFileError =
- watch("animation_url") instanceof File
- ? errors?.animation_url
- : watch("external_url") instanceof File
- ? errors?.external_url
- : watch("image") instanceof File
- ? errors?.image
- : undefined;
-
- const externalUrl = watch("external_url");
- const externalIsTextFile =
- externalUrl instanceof File &&
- (externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
-
- const showCoverImageUpload =
- watch("animation_url") instanceof File ||
- watch("external_url") instanceof File;
+ const {
+ media,
+ image,
+ mediaFileError,
+ showCoverImageUpload,
+ animation_url,
+ external_url,
+ } = getUploadedNFTMediaMeta(form);
const lazyMintNotifications = useTxNotifications(
"NFT lazy minted successfully",
@@ -184,7 +134,7 @@ export const LazyMintNftForm: React.FC = ({
})}
>
@@ -197,13 +147,14 @@ export const LazyMintNftForm: React.FC = ({
@@ -219,8 +170,9 @@ export const LazyMintNftForm: React.FC = ({
Cover Image
setValue("image", file)}
className="rounded border border-border transition-all"
@@ -245,6 +197,7 @@ export const LazyMintNftForm: React.FC = ({
control={control}
register={register}
setValue={setValue}
+ client={contract.client}
/>
= ({
{errors?.background_color?.message}
- {!externalIsTextFile && (
+
+ {!(external_url instanceof File) && (
External URL
@@ -286,47 +240,45 @@ export const LazyMintNftForm: React.FC = ({
)}
-
- Image URL
- {
- setValue("image", e.target.value);
- }}
- />
-
- If you already have your NFT image pre-uploaded to a URL, you
- can specify it here instead of uploading the media file
-
- {errors?.image?.message}
-
-
- Animation URL
- {
- setValue("animation_url", e.target.value);
- }}
- />
-
- If you already have your NFT Animation URL pre-uploaded to a
- URL, you can specify it here instead of uploading the media
- file
-
-
- {errors?.animation_url?.message}
-
-
+
+ {!(image instanceof File) && (
+
+ Image URL
+ {
+ setValue("image", e.target.value);
+ }}
+ />
+
+ If you already have your NFT image pre-uploaded to a URL,
+ you can specify it here instead of uploading the media file
+
+ {errors?.image?.message}
+
+ )}
+
+ {!(animation_url instanceof File) && (
+
+ Animation URL
+ {
+ setValue("animation_url", e.target.value);
+ }}
+ />
+
+ If you already have your NFT Animation URL pre-uploaded to a
+ URL, you can specify it here instead of uploading the media
+ file
+
+
+ {errors?.animation_url?.message}
+
+
+ )}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/mint-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/mint-form.tsx
index 37026578bf9..67fce1df341 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/mint-form.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/mint-form.tsx
@@ -16,7 +16,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
-import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
@@ -34,6 +33,10 @@ import {
} from "tw-components";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
+import {
+ getUploadedNFTMediaMeta,
+ handleNFTMediaUpload,
+} from "../../modules/components/nft/handleNFTMediaUpload";
const MINT_FORM_ID = "nft-mint-form";
@@ -52,6 +55,12 @@ export const NFTMintForm: React.FC = ({
}) => {
const trackEvent = useTrack();
const address = useActiveAccount()?.address;
+ const form = useForm<
+ NFTMetadataInputLimited & {
+ supply: number;
+ }
+ >();
+
const {
setValue,
control,
@@ -59,78 +68,20 @@ export const NFTMintForm: React.FC = ({
watch,
handleSubmit,
formState: { errors, isDirty },
- } = useForm<
- NFTMetadataInputLimited & {
- supply: number;
- }
- >();
+ } = form;
const setFile = (file: File) => {
- if (file.type.includes("image")) {
- // image files
- setValue("image", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- } else if (
- ["audio", "video", "text/html", "model/*"].some((type: string) =>
- file.type.includes(type),
- ) ||
- file.name?.endsWith(".glb") ||
- file.name?.endsWith(".usdz") ||
- file.name?.endsWith(".gltf") ||
- file.name.endsWith(".obj")
- ) {
- // audio, video, html, and glb (3d) files
- setValue("animation_url", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- } else if (
- ["text", "application/pdf"].some((type: string) =>
- file.type?.includes(type),
- )
- ) {
- // text and pdf files
- setValue("external_url", file);
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- }
+ handleNFTMediaUpload({ file, form });
};
- const imageUrl = useImageFileOrUrl(watch("image") as File | string);
- const animationUrlFormValue = watch("animation_url");
- const imageUrlFormValue = watch("image");
- const mediaFileUrl =
- watch("animation_url") instanceof File
- ? watch("animation_url")
- : watch("external_url") instanceof File
- ? watch("external_url")
- : watch("image") instanceof File
- ? imageUrl
- : undefined;
-
- const mediaFileError =
- watch("animation_url") instanceof File
- ? errors?.animation_url
- : watch("external_url") instanceof File
- ? errors?.external_url
- : watch("image") instanceof File
- ? errors?.image
- : undefined;
-
- const externalUrl = watch("external_url");
- const externalIsTextFile =
- externalUrl instanceof File &&
- (externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
-
- const showCoverImageUpload =
- watch("animation_url") instanceof File ||
- watch("external_url") instanceof File;
+ const {
+ media,
+ image,
+ mediaFileError,
+ showCoverImageUpload,
+ animation_url,
+ external_url,
+ } = getUploadedNFTMediaMeta(form);
const sendAndConfirmTx = useSendAndConfirmTransaction();
const nftMintNotifications = useTxNotifications(
@@ -198,7 +149,7 @@ export const NFTMintForm: React.FC = ({
})}
>
@@ -211,7 +162,8 @@ export const NFTMintForm: React.FC = ({
= ({
Cover Image
setValue("image", file)}
className="shrink-0 rounded border border-border transition-all"
@@ -266,6 +219,7 @@ export const NFTMintForm: React.FC = ({
)}
= ({
Advanced Options
-
+
Background Color
@@ -296,7 +250,8 @@ export const NFTMintForm: React.FC = ({
{errors?.background_color?.message}
- {!externalIsTextFile && (
+
+ {!(external_url instanceof File) && (
External URL
@@ -312,44 +267,42 @@ export const NFTMintForm: React.FC = ({
)}
-
- Image URL
- {
- setValue("image", e.target.value);
- }}
- />
-
- If you already have your NFT image pre-uploaded to a URL, you
- can specify it here instead of uploading the asset
-
- {errors?.image?.message}
-
-
- Animation URL
- {
- setValue("animation_url", e.target.value);
- }}
- />
-
- If you already have your NFT Animation URL pre-uploaded to a
- URL, you can specify it here instead of uploading the asset
-
-
- {errors?.animation_url?.message}
-
-
+
+ {!(image instanceof File) && (
+
+ Image URL
+ {
+ setValue("image", e.target.value);
+ }}
+ />
+
+ If you already have your NFT image pre-uploaded to a URL,
+ you can specify it here instead of uploading the asset
+
+ {errors?.image?.message}
+
+ )}
+
+ {!(animation_url instanceof File) && (
+
+ Animation URL
+ {
+ setValue("animation_url", e.target.value);
+ }}
+ />
+
+ If you already have your NFT Animation URL pre-uploaded to a
+ URL, you can specify it here instead of uploading the asset
+
+
+ {errors?.animation_url?.message}
+
+
+ )}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/shared-metadata-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/shared-metadata-form.tsx
index afa81a8359b..88cc8f4e313 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/shared-metadata-form.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/shared-metadata-form.tsx
@@ -14,7 +14,6 @@ import {
import { TransactionButton } from "components/buttons/TransactionButton";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
-import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
@@ -31,6 +30,10 @@ import {
} from "tw-components";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
+import {
+ getUploadedNFTMediaMeta,
+ handleNFTMediaUpload,
+} from "../../modules/components/nft/handleNFTMediaUpload";
const SHARED_METADATA_FORM_ID = "shared-metadata-form";
@@ -42,75 +45,20 @@ export const SharedMetadataForm: React.FC<{
const trackEvent = useTrack();
const address = useActiveAccount()?.address;
const sendAndConfirmTx = useSendAndConfirmTransaction();
+ const form = useForm();
const {
setValue,
register,
- watch,
handleSubmit,
formState: { errors, isDirty },
- } = useForm();
+ } = form;
const setFile = (file: File) => {
- if (file.type.includes("image")) {
- // image files
- setValue("image", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- } else if (
- ["audio", "video", "text/html", "model/*"].some((type: string) =>
- file.type.includes(type),
- ) ||
- file.name?.endsWith(".glb") ||
- file.name?.endsWith(".usdz") ||
- file.name?.endsWith(".gltf") ||
- file.name.endsWith(".obj")
- ) {
- // audio, video, html, and glb (3d) files
- setValue("animation_url", file);
- if (watch("external_url") instanceof File) {
- setValue("external_url", undefined);
- }
- } else if (
- ["text", "application/pdf"].some((type: string) =>
- file.type?.includes(type),
- )
- ) {
- // text and pdf files
- setValue("external_url", file);
- if (watch("animation_url") instanceof File) {
- setValue("animation_url", undefined);
- }
- }
+ handleNFTMediaUpload({ file, form });
};
- const imageUrl = useImageFileOrUrl(watch("image") as File | string);
- const animationUrlFormValue = watch("animation_url");
- const imageUrlFormValue = watch("image");
- const mediaFileUrl =
- watch("animation_url") instanceof File
- ? watch("animation_url")
- : watch("external_url") instanceof File
- ? watch("external_url")
- : watch("image") instanceof File
- ? imageUrl
- : undefined;
-
- const mediaFileError =
- watch("animation_url") instanceof File
- ? errors?.animation_url
- : watch("external_url") instanceof File
- ? errors?.external_url
- : watch("image") instanceof File
- ? errors?.image
- : undefined;
-
- const showCoverImageUpload =
- watch("animation_url") instanceof File ||
- watch("external_url") instanceof File;
+ const { media, image, mediaFileError, showCoverImageUpload, animation_url } =
+ getUploadedNFTMediaMeta(form);
const setSharedMetaNotifications = useTxNotifications(
"Shared metadata updated successfully",
@@ -185,7 +133,8 @@ export const SharedMetadataForm: React.FC<{
Cover Image
setValue("image", file)}
className="shrink-0 rounded border border-border transition-all"
@@ -237,45 +187,42 @@ export const SharedMetadataForm: React.FC<{
Advanced Options
-
-
- Image URL
- {
- setValue("image", e.target.value);
- }}
- />
-
- If you already have your NFT image pre-uploaded, you can set
- the URL or URI here.
-
- {errors?.image?.message}
-
-
- Animation URL
- {
- setValue("animation_url", e.target.value);
- }}
- />
-
- If you already have your NFT Animation URL pre-uploaded, you
- can set the URL or URI here.
-
-
- {errors?.animation_url?.message}
-
-
+
+ {!(image instanceof File) && (
+
+ Image URL
+ {
+ setValue("image", e.target.value);
+ }}
+ />
+
+ If you already have your NFT image pre-uploaded, you can set
+ the URL or URI here.
+
+ {errors?.image?.message}
+
+ )}
+
+ {!(animation_url instanceof File) && (
+
+ Animation URL
+ {
+ setValue("animation_url", e.target.value);
+ }}
+ />
+
+ If you already have your NFT Animation URL pre-uploaded, you
+ can set the URL or URI here.
+
+
+ {errors?.animation_url?.message}
+
+
+ )}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx
index ca42ff04ea3..34e2ed09b68 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx
@@ -139,7 +139,7 @@ export const NFTGetAllTable: React.FC = ({
}
if (isErc1155) {
cols.push({
- Header: "Supply",
+ Header: "Circulating Supply",
accessor: (row) => row,
Cell: (cell: CellProps) => {
if (cell.row.original.type === "ERC1155") {
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/components/metadata.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/components/metadata.tsx
index 7dc46eacd0c..eb452f6a9d8 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/components/metadata.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/components/metadata.tsx
@@ -14,7 +14,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { FileInput } from "components/shared/FileInput";
import { CommonContractSchema } from "constants/schemas";
import { useTrack } from "hooks/analytics/useTrack";
-import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useMemo } from "react";
@@ -224,9 +223,10 @@ export const SettingsMetadata = ({
>
Image
setValue("image", file, {
shouldTouch: true,
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx
index bb292611fbc..7b715894668 100644
--- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx
+++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx
@@ -6,6 +6,7 @@ import { Link } from "@chakra-ui/react";
import { useCsvUpload } from "hooks/useCsvUpload";
import { CircleAlertIcon, UploadIcon } from "lucide-react";
import { useMemo, useRef } from "react";
+import { useDropzone } from "react-dropzone";
import type { Column } from "react-table";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { Button, Heading, Text } from "tw-components";
@@ -36,19 +37,14 @@ export const AirdropUpload: React.FC = ({
onClose,
client,
}) => {
- const {
- normalizeQuery,
- getInputProps,
- getRootProps,
- isDragActive,
- rawData,
- noCsv,
- reset,
- removeInvalid,
- } = useCsvUpload({ csvParser, client });
+ const csvUpload = useCsvUpload({ csvParser, client });
+ const dropzone = useDropzone({
+ onDrop: csvUpload.setFiles,
+ });
+
const paginationPortalRef = useRef(null);
- const normalizeData = normalizeQuery.data;
+ const normalizeData = csvUpload.normalizeQuery.data;
const columns = useMemo(() => {
return [
@@ -107,11 +103,11 @@ export const AirdropUpload: React.FC = ({
return (
- {normalizeData.result.length && rawData.length > 0 ? (
+ {normalizeData.result.length && csvUpload.rawData.length > 0 ? (
<>
portalRef={paginationPortalRef}
- data={normalizeQuery.data.result}
+ data={csvUpload.normalizeQuery.data.result}
columns={columns}
/>
@@ -119,21 +115,21 @@ export const AirdropUpload: React.FC
= ({
- {normalizeQuery.data.invalidFound ? (
+ {csvUpload.normalizeQuery.data.invalidFound ? (
@@ -159,18 +155,18 @@ export const AirdropUpload: React.FC
= ({
diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx
index a8508452e42..4998ad6eab3 100644
--- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx
+++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { teamStub } from "stories/stubs";
-import { storybookLog } from "stories/utils";
+import { storybookLog, storybookThirdwebClient } from "stories/utils";
import { TeamOnboardingLayout } from "../onboarding-layout";
import { TeamInfoFormUI } from "./TeamInfoForm";
@@ -35,6 +35,7 @@ function Story(props: {
return (
{
storybookLog("onComplete");
}}
diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.tsx
index f0f08859feb..c5fa09e6093 100644
--- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.tsx
+++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/TeamInfoForm.tsx
@@ -18,6 +18,7 @@ import { ArrowRightIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
+import type { ThirdwebClient } from "thirdweb";
import { useDebounce } from "use-debounce";
import { z } from "zod";
import {
@@ -46,6 +47,7 @@ export function TeamInfoFormUI(props: {
onComplete: (updatedTeam: Team) => void;
isTeamSlugAvailable: (slug: string) => Promise;
teamSlug: string;
+ client: ThirdwebClient;
}) {
const form = useForm({
resolver: zodResolver(formSchema),
@@ -159,6 +161,7 @@ export function TeamInfoFormUI(props: {
onChange(file)}
diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/team-onboarding.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/team-onboarding.tsx
index ada5027196e..54bb9b18093 100644
--- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/team-onboarding.tsx
+++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/team-onboarding.tsx
@@ -38,6 +38,7 @@ export function TeamInfoForm(props: {
return res.data.result;
}}
teamSlug={props.teamSlug}
+ client={props.client}
onComplete={(updatedTeam) => {
router.replace(`/get-started/team/${updatedTeam.slug}/add-members`);
}}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx
index 82ca6113ee0..301a2680917 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx
@@ -230,7 +230,7 @@ export function AuthOptionsForm({
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/cards.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/cards.tsx
index c9ba6684621..d1c93a30421 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/cards.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/cards.tsx
@@ -1,6 +1,5 @@
"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";
@@ -34,18 +33,17 @@ export function Cards(props: {
void;
icon: React.FC<{ className?: string }>;
trackingLabel: string;
- badge?: string;
}) {
const { onClick } = props;
const isClickable = !!onClick || !!props.href;
@@ -111,17 +108,6 @@ function CardLink(props: {
- {props.badge && (
-
-
- {props.badge}
-
-
- )}
-
{props.href ? (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/PageHeader.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/PageHeader.tsx
new file mode 100644
index 00000000000..4bd5121591f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/PageHeader.tsx
@@ -0,0 +1,56 @@
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { cn } from "@/lib/utils";
+import Link from "next/link";
+
+export function CreateAssetPageHeader(props: {
+ teamSlug: string;
+ projectSlug: string;
+ title: string;
+ description: string;
+ containerClassName: string;
+}) {
+ return (
+
+
+
+
+
+
+
+ Assets
+
+
+
+
+
+ {props.title}
+
+
+
+
+
+
+
+
+ {props.title}
+
+
{props.description}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/SocialUrls.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/SocialUrls.tsx
new file mode 100644
index 00000000000..0b3fcab5579
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/SocialUrls.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { PlusIcon, Trash2Icon } from "lucide-react";
+import { type UseFormReturn, useFieldArray } from "react-hook-form";
+
+type WithSocialUrls = {
+ socialUrls: {
+ url: string;
+ platform: string;
+ }[];
+};
+
+export function SocialUrlsFieldset(props: {
+ form: UseFormReturn;
+}) {
+ // T contains all properties of WithSocialUrls, so this is ok
+ const form = props.form as unknown as UseFormReturn;
+
+ 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]/(sidebar)/assets/create/_common/chain-overview.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/chain-overview.tsx
new file mode 100644
index 00000000000..9686de9a5a9
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/chain-overview.tsx
@@ -0,0 +1,24 @@
+import { ChainIconClient } from "components/icons/ChainIcon";
+import { useAllChainsData } from "hooks/chains/allChains";
+import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
+
+export 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]/(sidebar)/assets/create/_common/download-file-button.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/download-file-button.tsx
new file mode 100644
index 00000000000..5c7b765e0cd
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/download-file-button.tsx
@@ -0,0 +1,42 @@
+import { Button } from "@/components/ui/button";
+import { ArrowDownToLineIcon } from "lucide-react";
+
+export function handleDownload(params: {
+ fileContent: string;
+ fileNameWithExtension: string;
+ fileFormat: "text/csv" | "application/json";
+}) {
+ const link = document.createElement("a");
+ const blob = new Blob([params.fileContent], {
+ type: params.fileFormat,
+ });
+ const objectURL = URL.createObjectURL(blob);
+ link.href = objectURL;
+ link.download = params.fileNameWithExtension;
+ link.click();
+ URL.revokeObjectURL(objectURL);
+}
+
+export function DownloadFileButton(props: {
+ fileContent: string;
+ fileNameWithExtension: string;
+ fileFormat: "text/csv" | "application/json";
+ label: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/file-preview.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/file-preview.tsx
new file mode 100644
index 00000000000..2e001efef17
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/file-preview.tsx
@@ -0,0 +1,64 @@
+import { Img } from "@/components/blocks/Img";
+import { fileToBlobUrl } from "@/lib/file-to-url";
+import { cn } from "@/lib/utils";
+import { ImageOffIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import type { ThirdwebClient } from "thirdweb";
+import { MediaRenderer } from "thirdweb/react";
+
+export function FilePreview(props: {
+ srcOrFile: File | string | undefined;
+ fallback?: React.ReactNode;
+ className?: string;
+ client: ThirdwebClient;
+ imgContainerClassName?: string;
+}) {
+ const [objectUrl, setObjectUrl] = useState("");
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (props.srcOrFile instanceof File) {
+ const url = fileToBlobUrl(props.srcOrFile);
+ setObjectUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else if (typeof props.srcOrFile === "string") {
+ setObjectUrl(props.srcOrFile);
+ } else {
+ setObjectUrl("");
+ }
+ }, [props.srcOrFile]);
+
+ // shortcut just for images
+ const isImage =
+ props.srcOrFile instanceof File &&
+ props.srcOrFile.type.startsWith("image/");
+
+ if (!objectUrl) {
+ return (
+
+
+
+ );
+ }
+
+ if (isImage) {
+ return (
+
+ );
+ }
+
+ return (
+ div]:!bg-muted")}
+ />
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/schema.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/schema.ts
new file mode 100644
index 00000000000..c344c60dd44
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/schema.ts
@@ -0,0 +1,35 @@
+import { isAddress } from "thirdweb";
+import * as z from "zod";
+
+const urlSchema = z.string().url();
+
+export const socialUrlsSchema = 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).success;
+ },
+ {
+ message: "Invalid URL",
+ },
+ ),
+ }),
+);
+
+export const addressSchema = z.string().refine(
+ (value) => {
+ if (isAddress(value)) {
+ return true;
+ }
+ return false;
+ },
+ {
+ message: "Invalid address",
+ },
+);
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/step-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/step-card.tsx
new file mode 100644
index 00000000000..ee1331d5ace
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/step-card.tsx
@@ -0,0 +1,98 @@
+import { Button } from "@/components/ui/button";
+import { useTrack } from "hooks/analytics/useTrack";
+import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
+import { getStepCardTrackingData } from "../token/_common/tracking";
+
+export function StepCard(props: {
+ title: string;
+ tracking: {
+ page: string;
+ contractType: "DropERC20" | "NFTCollection";
+ };
+ prevButton:
+ | undefined
+ | {
+ onClick: () => void;
+ };
+ nextButton:
+ | undefined
+ | {
+ type: "submit";
+ disabled?: boolean;
+ }
+ | {
+ type: "custom";
+ custom: React.ReactNode;
+ }
+ | {
+ type: "click";
+ disabled?: boolean;
+ onClick: () => void;
+ };
+ children: React.ReactNode;
+}) {
+ const trackEvent = useTrack();
+ const nextButton = props.nextButton;
+ return (
+
+
+ {props.title}
+
+
+ {props.children}
+
+ {(props.prevButton || props.nextButton) && (
+
+ {props.prevButton && (
+
+ )}
+
+ {nextButton && nextButton.type !== "custom" && (
+
+ )}
+
+ {props.nextButton &&
+ props.nextButton.type === "custom" &&
+ props.nextButton.custom}
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-card.tsx
deleted file mode 100644
index f46166dce68..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-card.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-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";
- disabled?: boolean;
- }
- | {
- 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]/(sidebar)/assets/create/nft/_common/form.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/form.ts
new file mode 100644
index 00000000000..03cac30d6f4
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/form.ts
@@ -0,0 +1,64 @@
+import { isAddress } from "thirdweb";
+import * as z from "zod";
+import { socialUrlsSchema } from "../../_common/schema";
+import type { NFTMetadataWithPrice } from "../upload-nfts/batch-upload/process-files";
+
+export const nftCollectionInfoFormSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+ symbol: z.string(),
+ chain: z.string().min(1, "Chain is required"),
+ description: z.string().optional(),
+ image: z.instanceof(File).optional(),
+ socialUrls: socialUrlsSchema,
+});
+
+const addressSchema = z.string().refine((value) => {
+ if (isAddress(value)) {
+ return true;
+ }
+
+ return false;
+});
+
+export const nftSalesSettingsFormSchema = z.object({
+ royaltyRecipient: addressSchema,
+ primarySaleRecipient: addressSchema,
+ royaltyBps: z.coerce.number().min(0).max(10000),
+});
+
+export type NFTCollectionInfoFormValues = z.infer<
+ typeof nftCollectionInfoFormSchema
+>;
+
+export type CreateNFTCollectionAllValues = {
+ collectionInfo: NFTCollectionInfoFormValues;
+ nfts: NFTMetadataWithPrice[];
+ sales: NFTSalesSettingsFormValues;
+};
+
+export type CreateNFTCollectionFunctions = {
+ erc721: {
+ deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
+ contractAddress: string;
+ }>;
+ setClaimConditions: (values: CreateNFTCollectionAllValues) => Promise;
+ lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise;
+ };
+ erc1155: {
+ deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
+ contractAddress: string;
+ }>;
+ setClaimConditions: (params: {
+ values: CreateNFTCollectionAllValues;
+ batch: {
+ startIndex: number;
+ count: number;
+ };
+ }) => Promise;
+ lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise;
+ };
+};
+
+export type NFTSalesSettingsFormValues = z.infer<
+ typeof nftSalesSettingsFormSchema
+>;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/pages.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/pages.ts
new file mode 100644
index 00000000000..f86b3dec638
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/pages.ts
@@ -0,0 +1,6 @@
+export const nftCreationPages = {
+ "collection-info": "collection-info",
+ "upload-assets": "upload-assets",
+ "sales-settings": "sales-settings",
+ "launch-nft": "launch-nft",
+} as const;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/tracking.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/tracking.ts
new file mode 100644
index 00000000000..4628cb2ae47
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/tracking.ts
@@ -0,0 +1,91 @@
+// example: asset.claim-conditions.attempt
+export function getNFTStepTrackingData(
+ params: {
+ action: "claim-conditions" | "lazy-mint" | "mint" | "deploy";
+ ercType: "erc721" | "erc1155";
+ chainId: number;
+ } & (
+ | {
+ status: "attempt" | "success";
+ }
+ | {
+ status: "error";
+ errorMessage: string;
+ }
+ ),
+) {
+ return {
+ category: "asset",
+ action: params.action,
+ contractType: params.ercType === "erc721" ? "DropERC721" : "DropERC1155",
+ label: params.status,
+ chainId: params.chainId,
+ ...(params.status === "error"
+ ? {
+ errorMessage: params.errorMessage,
+ }
+ : {}),
+ };
+}
+
+export function getNFTLaunchTrackingData(
+ params: {
+ chainId: number;
+ ercType: "erc721" | "erc1155";
+ } & (
+ | {
+ type: "attempt" | "success";
+ }
+ | {
+ type: "error";
+ errorMessage: string;
+ }
+ ),
+) {
+ return {
+ category: "asset",
+ action: "launch",
+ label: params.type,
+ contractType: "NFTCollection",
+ ercType: params.ercType,
+ chainId: params.chainId,
+ ...(params.type === "error"
+ ? {
+ errorMessage: params.errorMessage,
+ }
+ : {}),
+ };
+}
+
+export function getNFTDeploymentTrackingData(
+ params: {
+ chainId: number;
+ ercType: "erc721" | "erc1155";
+ } & (
+ | {
+ type: "attempt" | "success";
+ }
+ | {
+ type: "error";
+ errorMessage: string;
+ }
+ ),
+) {
+ // using "custom-contract" because it has to match the main deployment tracking format
+ return {
+ category: "custom-contract",
+ action: "deploy",
+ label: params.type,
+ publisherAndContractName:
+ params.ercType === "erc721"
+ ? "deployer.thirdweb.eth/DropERC721"
+ : "deployer.thirdweb.eth/DropERC1155",
+ chainId: params.chainId,
+ deploymentType: "asset",
+ ...(params.type === "error"
+ ? {
+ errorMessage: params.errorMessage,
+ }
+ : {}),
+ };
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/collection-info/nft-collection-info-fieldset.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/collection-info/nft-collection-info-fieldset.tsx
new file mode 100644
index 00000000000..99af8ef0047
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/collection-info/nft-collection-info-fieldset.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
+import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
+import { Form } 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 type { UseFormReturn } from "react-hook-form";
+import type { ThirdwebClient } from "thirdweb";
+import { SocialUrlsFieldset } from "../../_common/SocialUrls";
+import { StepCard } from "../../_common/step-card";
+import type { NFTCollectionInfoFormValues } from "../_common/form";
+import { nftCreationPages } from "../_common/pages";
+
+export function NFTCollectionInfoFieldset(props: {
+ client: ThirdwebClient;
+ onNext: () => void;
+ form: UseFormReturn;
+ onChainUpdated: () => void;
+}) {
+ const { form } = props;
+ return (
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page-ui.tsx
new file mode 100644
index 00000000000..441f33387f6
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page-ui.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import {
+ NATIVE_TOKEN_ADDRESS,
+ type ThirdwebClient,
+ getAddress,
+} from "thirdweb";
+import { useActiveAccount } from "thirdweb/react";
+import {
+ type CreateNFTCollectionFunctions,
+ type NFTCollectionInfoFormValues,
+ type NFTSalesSettingsFormValues,
+ nftCollectionInfoFormSchema,
+ nftSalesSettingsFormSchema,
+} from "./_common/form";
+import { nftCreationPages } from "./_common/pages";
+import { NFTCollectionInfoFieldset } from "./collection-info/nft-collection-info-fieldset";
+import { LaunchNFT } from "./launch/launch-nft";
+import { SalesSettings } from "./sales/sales-settings";
+import type {} from "./upload-nfts/batch-upload/process-files";
+import { type NFTData, UploadNFTsFieldset } from "./upload-nfts/upload-nfts";
+
+export function CreateNFTPageUI(props: {
+ accountAddress: string;
+ client: ThirdwebClient;
+ createNFTFunctions: CreateNFTCollectionFunctions;
+ onLaunchSuccess: () => void;
+ teamSlug: string;
+ projectSlug: string;
+}) {
+ const [step, setStep] =
+ useState("collection-info");
+
+ const activeAccount = useActiveAccount();
+
+ const [nftData, setNFTData] = useState({
+ type: "single",
+ nft: null,
+ });
+
+ const nftSalesSettingsForm = useForm({
+ resolver: zodResolver(nftSalesSettingsFormSchema),
+ defaultValues: {
+ royaltyRecipient: activeAccount?.address || "",
+ primarySaleRecipient: activeAccount?.address || "",
+ royaltyBps: 0,
+ },
+ });
+
+ const nftCollectionInfoForm = useNFTCollectionInfoForm();
+
+ return (
+
+ {step === nftCreationPages["collection-info"] && (
+ {
+ // reset price currency to native token when chain is updated
+ if (nftData.type === "single" && nftData.nft) {
+ setNFTData({
+ type: "single",
+ nft: {
+ ...nftData.nft,
+ price_currency: getAddress(NATIVE_TOKEN_ADDRESS),
+ },
+ });
+ }
+
+ if (nftData.type === "multiple" && nftData.nfts?.type === "data") {
+ setNFTData({
+ type: "multiple",
+ nfts: {
+ type: "data",
+ data: nftData.nfts.data.map((x) => {
+ return {
+ ...x,
+ price_currency: getAddress(NATIVE_TOKEN_ADDRESS),
+ };
+ }),
+ },
+ });
+ }
+ }}
+ client={props.client}
+ form={nftCollectionInfoForm}
+ onNext={() => {
+ setStep(nftCreationPages["upload-assets"]);
+ }}
+ />
+ )}
+
+ {step === nftCreationPages["upload-assets"] && (
+ {
+ setStep(nftCreationPages["sales-settings"]);
+ }}
+ onPrev={() => {
+ setStep(nftCreationPages["collection-info"]);
+ }}
+ client={props.client}
+ chainId={Number(nftCollectionInfoForm.watch("chain"))}
+ />
+ )}
+
+ {step === nftCreationPages["sales-settings"] && (
+ {
+ setStep(nftCreationPages["launch-nft"]);
+ }}
+ onPrev={() => {
+ setStep(nftCreationPages["upload-assets"]);
+ }}
+ />
+ )}
+
+ {step === nftCreationPages["launch-nft"] && (
+ {
+ setStep(nftCreationPages["sales-settings"]);
+ }}
+ projectSlug={props.projectSlug}
+ client={props.client}
+ onLaunchSuccess={props.onLaunchSuccess}
+ teamSlug={props.teamSlug}
+ createNFTFunctions={props.createNFTFunctions}
+ />
+ )}
+
+ );
+}
+
+function useNFTCollectionInfoForm() {
+ return useForm({
+ resolver: zodResolver(nftCollectionInfoFormSchema),
+ values: {
+ name: "",
+ description: "",
+ symbol: "",
+ image: undefined,
+ chain: "1",
+ socialUrls: [
+ {
+ platform: "Website",
+ url: "",
+ },
+ {
+ platform: "Twitter",
+ url: "",
+ },
+ ],
+ },
+ reValidateMode: "onChange",
+ });
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page.tsx
new file mode 100644
index 00000000000..6304fe181f7
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page.tsx
@@ -0,0 +1,503 @@
+"use client";
+import { revalidatePathAction } from "@/actions/revalidate";
+import { useTrack } from "hooks/analytics/useTrack";
+import { useRef } from "react";
+import {
+ type ThirdwebClient,
+ defineChain,
+ encode,
+ getContract,
+ sendAndConfirmTransaction,
+} from "thirdweb";
+import { deployERC721Contract, deployERC1155Contract } from "thirdweb/deploys";
+import { multicall } from "thirdweb/extensions/common";
+import {
+ lazyMint as lazyMint721,
+ setClaimConditions as setClaimConditions721,
+} from "thirdweb/extensions/erc721";
+import {
+ getNFTs as getNFTs1155,
+ lazyMint as lazyMint1155,
+ setClaimConditions as setClaimConditions1155,
+} from "thirdweb/extensions/erc1155";
+import { useActiveAccount } from "thirdweb/react";
+import { maxUint256 } from "thirdweb/utils";
+import { parseError } from "utils/errorParser";
+import { useAddContractToProject } from "../../../hooks/project-contracts";
+import type { CreateNFTCollectionAllValues } from "./_common/form";
+import {
+ getNFTDeploymentTrackingData,
+ getNFTStepTrackingData,
+} from "./_common/tracking";
+import { CreateNFTPageUI } from "./create-nft-page-ui";
+
+export function CreateNFTPage(props: {
+ accountAddress: string;
+ client: ThirdwebClient;
+ teamSlug: string;
+ projectSlug: string;
+ teamId: string;
+ projectId: string;
+}) {
+ const activeAccount = useActiveAccount();
+ const trackEvent = useTrack();
+ const addContractToProject = useAddContractToProject();
+ const contractAddressRef = useRef(undefined);
+
+ function getContractAndAccount(params: {
+ chain: string;
+ }) {
+ if (!activeAccount) {
+ throw new Error("Wallet is not connected");
+ }
+
+ // eslint-disable-next-line no-restricted-syntax
+ const chain = defineChain(Number(params.chain));
+
+ const contractAddress = contractAddressRef.current;
+
+ if (!contractAddress) {
+ throw new Error("Contract not found");
+ }
+
+ const contract = getContract({
+ address: contractAddress,
+ chain,
+ client: props.client,
+ });
+
+ return {
+ activeAccount,
+ contract,
+ };
+ }
+
+ async function handleContractDeploy(params: {
+ values: CreateNFTCollectionAllValues;
+ ercType: "erc721" | "erc1155";
+ }) {
+ const { values: formValues, ercType } = params;
+ const { collectionInfo, sales } = formValues;
+
+ if (!activeAccount) {
+ throw new Error("Wallet is not connected");
+ }
+
+ // eslint-disable-next-line no-restricted-syntax
+ const chain = defineChain(Number(collectionInfo.chain));
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "deploy",
+ ercType,
+ chainId: Number(collectionInfo.chain),
+ status: "attempt",
+ }),
+ );
+
+ trackEvent(
+ getNFTDeploymentTrackingData({
+ ercType,
+ type: "attempt",
+ chainId: Number(collectionInfo.chain),
+ }),
+ );
+
+ try {
+ let contractAddress: string;
+
+ if (ercType === "erc721") {
+ contractAddress = await deployERC721Contract({
+ account: activeAccount,
+ client: props.client,
+ chain: chain,
+ type: "DropERC721",
+ params: {
+ name: collectionInfo.name,
+ symbol: collectionInfo.symbol,
+ description: collectionInfo.description,
+ image: collectionInfo.image,
+ social_urls: transformSocialUrls(collectionInfo.socialUrls),
+ saleRecipient: sales.primarySaleRecipient,
+ royaltyRecipient: sales.royaltyRecipient,
+ royaltyBps: BigInt(sales.royaltyBps),
+ },
+ });
+ } else {
+ contractAddress = await deployERC1155Contract({
+ account: activeAccount,
+ client: props.client,
+ chain: chain,
+ type: "DropERC1155",
+ params: {
+ name: collectionInfo.name,
+ symbol: collectionInfo.symbol,
+ description: collectionInfo.description,
+ image: collectionInfo.image,
+ social_urls: transformSocialUrls(collectionInfo.socialUrls),
+ saleRecipient: sales.primarySaleRecipient,
+ royaltyRecipient: sales.royaltyRecipient,
+ royaltyBps: BigInt(sales.royaltyBps),
+ },
+ });
+ }
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "deploy",
+ ercType,
+ chainId: Number(collectionInfo.chain),
+ status: "success",
+ }),
+ );
+
+ trackEvent(
+ getNFTDeploymentTrackingData({
+ ercType,
+ type: "success",
+ chainId: Number(collectionInfo.chain),
+ }),
+ );
+
+ contractAddressRef.current = contractAddress;
+
+ // add contract to project in background
+ addContractToProject.mutateAsync({
+ teamId: props.teamId,
+ projectId: props.projectId,
+ contractAddress: contractAddress,
+ chainId: collectionInfo.chain,
+ deploymentType: "asset",
+ contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
+ });
+
+ return {
+ contractAddress,
+ };
+ } catch (error) {
+ const parsedError = parseError(error);
+ const errorMessage =
+ typeof parsedError === "string" ? parsedError : "Unknown error";
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "deploy",
+ ercType,
+ chainId: Number(collectionInfo.chain),
+ status: "error",
+ errorMessage,
+ }),
+ );
+
+ trackEvent(
+ getNFTDeploymentTrackingData({
+ ercType,
+ type: "error",
+ chainId: Number(collectionInfo.chain),
+ errorMessage,
+ }),
+ );
+
+ throw error;
+ }
+ }
+
+ async function handleLazyMintNFTs(params: {
+ formValues: CreateNFTCollectionAllValues;
+ ercType: "erc721" | "erc1155";
+ }) {
+ const { formValues, ercType } = params;
+
+ const { contract, activeAccount } = getContractAndAccount({
+ chain: formValues.collectionInfo.chain,
+ });
+
+ const lazyMintFn = ercType === "erc721" ? lazyMint721 : lazyMint1155;
+
+ const transaction = lazyMintFn({
+ contract,
+ nfts: formValues.nfts,
+ });
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "lazy-mint",
+ ercType,
+ chainId: Number(formValues.collectionInfo.chain),
+ status: "attempt",
+ }),
+ );
+
+ try {
+ await sendAndConfirmTransaction({
+ account: activeAccount,
+ transaction,
+ });
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "lazy-mint",
+ ercType,
+ chainId: Number(formValues.collectionInfo.chain),
+ status: "success",
+ }),
+ );
+ } catch (error) {
+ const parsedError = parseError(error);
+ const errorMessage =
+ typeof parsedError === "string" ? parsedError : "Unknown error";
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "lazy-mint",
+ ercType,
+ chainId: Number(formValues.collectionInfo.chain),
+ status: "error",
+ errorMessage,
+ }),
+ );
+
+ throw error;
+ }
+ }
+
+ async function handleSetClaimConditionsERC721(params: {
+ formValues: CreateNFTCollectionAllValues;
+ }) {
+ const { formValues } = params;
+ const { contract, activeAccount } = getContractAndAccount({
+ chain: formValues.collectionInfo.chain,
+ });
+
+ const firstNFT = formValues.nfts[0];
+ if (!firstNFT) {
+ throw new Error("No NFTs found");
+ }
+
+ const transaction = setClaimConditions721({
+ contract,
+ phases: [
+ {
+ startTime: new Date(),
+ currencyAddress: firstNFT.price_currency,
+ maxClaimablePerWallet: maxUint256, // unlimited
+ price: firstNFT.price_amount,
+ maxClaimableSupply: maxUint256, // unlimited
+ metadata: {
+ name: "Public phase",
+ },
+ },
+ ],
+ });
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "claim-conditions",
+ ercType: "erc721",
+ chainId: Number(formValues.collectionInfo.chain),
+ status: "attempt",
+ }),
+ );
+
+ try {
+ await sendAndConfirmTransaction({
+ account: activeAccount,
+ transaction,
+ });
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "claim-conditions",
+ ercType: "erc721",
+ chainId: Number(formValues.collectionInfo.chain),
+ status: "success",
+ }),
+ );
+ } catch (error) {
+ const parsedError = parseError(error);
+ const errorMessage =
+ typeof parsedError === "string" ? parsedError : "Unknown error";
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "claim-conditions",
+ ercType: "erc721",
+ chainId: Number(formValues.collectionInfo.chain),
+ status: "error",
+ errorMessage,
+ }),
+ );
+
+ throw error;
+ }
+ }
+
+ async function handleSetClaimConditionsERC1155(params: {
+ values: CreateNFTCollectionAllValues;
+ batch: {
+ startIndex: number;
+ count: number;
+ };
+ }) {
+ const { values, batch } = params;
+ const { contract, activeAccount } = getContractAndAccount({
+ chain: values.collectionInfo.chain,
+ });
+
+ const endIndexExclusive = batch.startIndex + batch.count;
+ const nfts = values.nfts.slice(batch.startIndex, endIndexExclusive);
+
+ // fetch nfts
+ const fetchedNFTs = await getNFTs1155({
+ contract,
+ start: batch.startIndex,
+ count: batch.count,
+ });
+
+ const transactions = nfts.map((uploadedNFT, i) => {
+ const fetchedNFT = fetchedNFTs[i];
+
+ if (!fetchedNFT) {
+ throw new Error("Failed to find NFT");
+ }
+
+ if (fetchedNFT.metadata.name !== uploadedNFT.name) {
+ throw new Error("Failed to find NFT in batch");
+ }
+
+ return setClaimConditions1155({
+ contract,
+ tokenId: fetchedNFT.id,
+ phases: [
+ {
+ startTime: new Date(),
+ currencyAddress: uploadedNFT.price_currency,
+ maxClaimablePerWallet: maxUint256, // unlimited
+ price: uploadedNFT.price_amount,
+ maxClaimableSupply: BigInt(uploadedNFT.supply),
+ metadata: {
+ name: "Public phase",
+ },
+ },
+ ],
+ });
+ });
+
+ const encodedTransactions = await Promise.all(
+ transactions.map((tx) => encode(tx)),
+ );
+
+ const tx = multicall({
+ contract: contract,
+ data: encodedTransactions,
+ });
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "claim-conditions",
+ ercType: "erc1155",
+ chainId: Number(values.collectionInfo.chain),
+ status: "attempt",
+ }),
+ );
+
+ try {
+ await sendAndConfirmTransaction({
+ transaction: tx,
+ account: activeAccount,
+ });
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "claim-conditions",
+ ercType: "erc1155",
+ chainId: Number(values.collectionInfo.chain),
+ status: "success",
+ }),
+ );
+ } catch (error) {
+ const parsedError = parseError(error);
+ const errorMessage =
+ typeof parsedError === "string" ? parsedError : "Unknown error";
+
+ trackEvent(
+ getNFTStepTrackingData({
+ action: "claim-conditions",
+ ercType: "erc1155",
+ chainId: Number(values.collectionInfo.chain),
+ status: "error",
+ errorMessage,
+ }),
+ );
+
+ throw error;
+ }
+ }
+
+ return (
+ {
+ revalidatePathAction(
+ `/team/${props.teamSlug}/project/${props.projectSlug}/assets`,
+ "page",
+ );
+ }}
+ createNFTFunctions={{
+ erc1155: {
+ lazyMintNFTs: (formValues) => {
+ return handleLazyMintNFTs({
+ formValues,
+ ercType: "erc1155",
+ });
+ },
+ deployContract: (formValues) => {
+ return handleContractDeploy({
+ values: formValues,
+ ercType: "erc1155",
+ });
+ },
+ setClaimConditions: async (params) => {
+ return handleSetClaimConditionsERC1155(params);
+ },
+ },
+ erc721: {
+ deployContract: (formValues) => {
+ return handleContractDeploy({
+ values: formValues,
+ ercType: "erc721",
+ });
+ },
+ lazyMintNFTs: (formValues) => {
+ return handleLazyMintNFTs({
+ formValues,
+ ercType: "erc721",
+ });
+ },
+
+ setClaimConditions: async (formValues) => {
+ return handleSetClaimConditionsERC721({
+ formValues,
+ });
+ },
+ },
+ }}
+ />
+ );
+}
+
+function transformSocialUrls(
+ socialUrls: {
+ platform: string;
+ url: string;
+ }[],
+): Record {
+ return socialUrls.reduce(
+ (acc, url) => {
+ if (url.url && url.platform) {
+ acc[url.platform] = url.url;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch/launch-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch/launch-nft.tsx
new file mode 100644
index 00000000000..4f6ed453f4f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch/launch-nft.tsx
@@ -0,0 +1,509 @@
+"use client";
+
+import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status";
+import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status";
+import { WalletAddress } from "@/components/blocks/wallet-address";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Skeleton } from "@/components/ui/skeleton";
+import { TransactionButton } from "components/buttons/TransactionButton";
+import { useTrack } from "hooks/analytics/useTrack";
+import {
+ ArrowRightIcon,
+ ArrowUpFromLineIcon,
+ ImageOffIcon,
+} from "lucide-react";
+import Link from "next/link";
+import { useMemo, useRef, useState } from "react";
+import { type ThirdwebClient, defineChain } from "thirdweb";
+import { TokenProvider, TokenSymbol, useActiveWallet } from "thirdweb/react";
+import { parseError } from "utils/errorParser";
+import { ChainOverview } from "../../_common/chain-overview";
+import { FilePreview } from "../../_common/file-preview";
+import { StepCard } from "../../_common/step-card";
+import type {
+ CreateNFTCollectionAllValues,
+ CreateNFTCollectionFunctions,
+} from "../_common/form";
+import { getNFTLaunchTrackingData } from "../_common/tracking";
+
+const stepIds = {
+ "deploy-contract": "deploy-contract",
+ "set-claim-conditions": "set-claim-conditions",
+ "mint-nfts": "mint-nfts",
+} as const;
+
+type StepId = keyof typeof stepIds;
+
+export function LaunchNFT(props: {
+ createNFTFunctions: CreateNFTCollectionFunctions;
+ values: CreateNFTCollectionAllValues;
+ onPrevious: () => void;
+ client: ThirdwebClient;
+ onLaunchSuccess: () => void;
+ teamSlug: string;
+ projectSlug: string;
+}) {
+ const formValues = props.values;
+ const [steps, setSteps] = useState[]>([]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const activeWallet = useActiveWallet();
+ const walletRequiresApproval = activeWallet?.id !== "inApp";
+ const [contractLink, setContractLink] = useState(null);
+ const trackEvent = useTrack();
+
+ 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 updateDescription(index: number, description: string) {
+ setSteps((prev) => {
+ return [
+ ...prev.slice(0, index),
+ { ...prev[index], description },
+ ...prev.slice(index + 1),
+ ] as MultiStepState[];
+ });
+ }
+
+ function launchTracking(
+ params:
+ | {
+ type: "attempt" | "success";
+ }
+ | {
+ type: "error";
+ errorMessage: string;
+ },
+ ) {
+ trackEvent(
+ getNFTLaunchTrackingData({
+ chainId: Number(formValues.collectionInfo.chain),
+ ercType: ercType,
+ ...params,
+ }),
+ );
+ }
+
+ async function handleSubmitClick() {
+ launchTracking({
+ type: "attempt",
+ });
+
+ const initialSteps: MultiStepState[] = [
+ {
+ label: "Deploy contract",
+ id: stepIds["deploy-contract"],
+ status: { type: "idle" },
+ },
+ {
+ label: formValues.nfts.length > 1 ? "Mint NFTs" : "Mint NFT",
+ id: stepIds["mint-nfts"],
+ status: { type: "idle" },
+ },
+ {
+ label:
+ formValues.nfts.length > 1
+ ? "Set claim conditions"
+ : "Set claim condition",
+ id: stepIds["set-claim-conditions"],
+ status: { type: "idle" },
+ },
+ ];
+
+ setSteps(initialSteps);
+ setIsModalOpen(true);
+ executeSteps(initialSteps, 0);
+ }
+
+ const batchesProcessedRef = useRef(0);
+ const batchSize = 50;
+ const batchCount =
+ formValues.nfts.length > batchSize
+ ? Math.ceil(formValues.nfts.length / batchSize)
+ : 1;
+
+ const ercType: "erc721" | "erc1155" = useMemo(() => {
+ // if all prices (amount + currency) are same and all supply is to 1
+ const shouldDeployERC721 = formValues.nfts.every((nft) => {
+ return (
+ nft.supply === "1" &&
+ formValues.nfts[0] &&
+ nft.price_amount === formValues.nfts[0].price_amount &&
+ nft.price_currency === formValues.nfts[0].price_currency
+ );
+ });
+
+ return shouldDeployERC721 ? "erc721" : "erc1155";
+ }, [formValues.nfts]);
+
+ async function executeStep(steps: MultiStepState[], stepId: StepId) {
+ if (stepId === "deploy-contract") {
+ const result =
+ await props.createNFTFunctions[ercType].deployContract(formValues);
+ setContractLink(
+ `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${result.contractAddress}`,
+ );
+ } else if (stepId === "set-claim-conditions") {
+ if (ercType === "erc721") {
+ await props.createNFTFunctions.erc721.setClaimConditions(formValues);
+ } else {
+ if (batchCount > 1) {
+ const batchStartIndex = batchesProcessedRef.current;
+ for (
+ let batchIndex = batchStartIndex;
+ batchIndex < batchCount;
+ batchIndex++
+ ) {
+ const index = steps.findIndex(
+ (s) => s.id === stepIds["set-claim-conditions"],
+ );
+
+ if (index !== -1) {
+ updateDescription(
+ index,
+ `Processing batch ${batchIndex + 1} of ${batchCount}`,
+ );
+ }
+
+ await props.createNFTFunctions.erc1155.setClaimConditions({
+ values: formValues,
+ batch: {
+ startIndex: batchIndex * batchSize,
+ count: batchSize,
+ },
+ });
+
+ batchesProcessedRef.current += 1;
+ }
+ } else {
+ await props.createNFTFunctions.erc1155.setClaimConditions({
+ values: formValues,
+ batch: {
+ startIndex: 0,
+ count: formValues.nfts.length,
+ },
+ });
+ }
+ }
+ } else if (stepId === "mint-nfts") {
+ await props.createNFTFunctions[ercType].lazyMintNFTs(formValues);
+ }
+ }
+
+ async function executeSteps(
+ steps: MultiStepState[],
+ startIndex: number,
+ ) {
+ for (let i = startIndex; i < steps.length; i++) {
+ const currentStep = steps[i];
+ if (!currentStep) {
+ return;
+ }
+
+ try {
+ updateStatus(i, {
+ type: "pending",
+ });
+
+ await executeStep(steps, currentStep.id);
+
+ updateStatus(i, {
+ type: "completed",
+ });
+ } catch (error) {
+ const parsedError = parseError(error);
+
+ updateStatus(i, {
+ type: "error",
+ message: parsedError,
+ });
+
+ launchTracking({
+ type: "error",
+ errorMessage:
+ typeof parsedError === "string" ? parsedError : "Unknown error",
+ });
+
+ throw error;
+ }
+ }
+
+ launchTracking({
+ type: "success",
+ });
+ props.onLaunchSuccess();
+ batchesProcessedRef.current = 0;
+ }
+
+ async function handleRetry(step: MultiStepState) {
+ const startIndex = steps.findIndex((s) => s.id === step.id);
+ if (startIndex === -1) {
+ return;
+ }
+
+ try {
+ await executeSteps(steps, startIndex);
+ } catch {
+ // no op
+ }
+ }
+
+ const isComplete = steps.every((step) => step.status.type === "completed");
+ const isPending = steps.some((step) => step.status.type === "pending");
+
+ const isPriceSame = props.values.nfts.every(
+ (nft) => nft.price_amount === props.values.nfts[0]?.price_amount,
+ );
+
+ const uniqueAttributes = useMemo(() => {
+ const attributeNames = new Set();
+ for (const nft of props.values.nfts) {
+ if (nft.attributes) {
+ for (const attribute of nft.attributes) {
+ if (attribute.trait_type && attribute.value) {
+ attributeNames.add(attribute.trait_type);
+ }
+ }
+ }
+ }
+ return Array.from(attributeNames);
+ }, [props.values.nfts]);
+
+ return (
+
+
+ Launch NFT
+
+ ),
+ }}
+ >
+
+
+ {/* Token info */}
+
+
Collection Info
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ {formValues.collectionInfo.symbol && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
NFTs
+
+
+
+
+
+ {isPriceSame && formValues.nfts[0] && (
+
+
+ {formValues.nfts[0].price_amount}{" "}
+
+ }
+ />
+
+
+
+ )}
+
+ {uniqueAttributes.length > 0 && (
+
+
+ {uniqueAttributes.map((attr) => {
+ return (
+
+ {attr}
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+
Sales and Fees
+
+
+
+
+
+
+
+
+
+
+
+ {Number(formValues.sales.royaltyBps) / 100}%
+
+
+
+
+
+ );
+}
+
+function OverviewField(props: {
+ name: string;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+
{props.name}
+ {props.children}
+
+ );
+}
+
+function OverviewFieldValue(props: {
+ value: string;
+}) {
+ return {props.value}
;
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/page.tsx
new file mode 100644
index 00000000000..62f830b7f6c
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/page.tsx
@@ -0,0 +1,66 @@
+import { getProject } from "@/api/projects";
+import { getTeamBySlug } from "@/api/team";
+
+import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
+import {
+ getAuthToken,
+ getAuthTokenWalletAddress,
+} from "@app/api/lib/getAuthToken";
+import { loginRedirect } from "@app/login/loginRedirect";
+import { redirect } from "next/navigation";
+import { CreateAssetPageHeader } from "../_common/PageHeader";
+import { CreateNFTPage } from "./create-nft-page";
+
+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/nft`,
+ );
+ }
+
+ if (!team) {
+ redirect("/team");
+ }
+
+ if (!project) {
+ redirect(`/team/${params.team_slug}`);
+ }
+
+ const client = getClientThirdwebClient({
+ jwt: authToken,
+ teamId: team.id,
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/sales/sales-settings.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/sales/sales-settings.tsx
new file mode 100644
index 00000000000..8c2066e7eb7
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/sales/sales-settings.tsx
@@ -0,0 +1,111 @@
+import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
+import { Form } from "@/components/ui/form";
+import { BasisPointsInput } from "components/inputs/BasisPointsInput";
+import type { UseFormReturn } from "react-hook-form";
+import type { ThirdwebClient } from "thirdweb";
+import { SolidityInput } from "../../../../../../../../../../contract-ui/components/solidity-inputs";
+import { StepCard } from "../../_common/step-card";
+import type { NFTSalesSettingsFormValues } from "../_common/form";
+import { nftCreationPages } from "../_common/pages";
+
+export function SalesSettings(props: {
+ onNext: () => void;
+ onPrev: () => void;
+ form: UseFormReturn;
+ client: ThirdwebClient;
+}) {
+ const errors = props.form.formState.errors;
+ const bpsNumValue = props.form.watch("royaltyBps");
+
+ return (
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx
new file mode 100644
index 00000000000..83df8c496e2
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx
@@ -0,0 +1,417 @@
+import { Button } from "@/components/ui/button";
+import { CodeClient } from "@/components/ui/code/code.client";
+import { InlineCode } from "@/components/ui/inline-code";
+import { TabButtons } from "@/components/ui/tabs";
+import { ArrowDownToLineIcon } from "lucide-react";
+import { useState } from "react";
+import { handleDownload } from "../../../_common/download-file-button";
+
+export function BatchUploadInstructions() {
+ const [tab, setTab] = useState<"csv" | "json">("csv");
+ return (
+
+
setTab("csv"),
+ isActive: tab === "csv",
+ },
+ {
+ name: "JSON",
+ onClick: () => setTab("json"),
+ isActive: tab === "json",
+ },
+ ]}
+ />
+
+
+
+
+
+
+ Make sure to drag and drop the {tab === "csv" ? "CSV" : "JSON"} and
+ the assets at the same time or upload a folder with all the files in
+ it.
+
+
+
+ Asset names must be sequential 0,1,2,3...n.[extension]. It
+ doesn't matter at what number you begin. Example: 131.png,
+ 132.png etc.
+
+
+
+ Images and other file types can be used in combination. They both
+ have to follow the asset naming convention above. Example: 0.png and
+ 0.mp4, 1.png and 1.glb etc.
+
+
+
+
+
+ The {tab === "csv" ? "CSV" : "JSON"} file must have a{" "}
+ {tab === "csv" ? "column" : "property"},
+ which defines the name of the NFT
+
+
+
+ Other optional {tab === "csv" ? "columns" : "properties"} are{" "}
+ ,{" "}
+ ,{" "}
+ ,{" "}
+ ,{" "}
+ ,{" "}
+ {" "}
+ {tab === "json" && (
+ <>
+ ,
+
+ >
+ )}{" "}
+ and
+ {". "}
+ {tab === "csv" &&
+ "All other columns will be treated as Attributes. For example: See 'foo', 'bar' and 'bazz' below."}
+
+
+
+
+
+
+
+ If you want to map your media files to your NFTs, you can add the
+ name of your files to the and{" "}
+ {" "}
+ {tab === "csv" ? "columns" : "properties"}.{" "}
+
+
+
+
+
+
+ When uploading files, assets will be uploaded and pinned to IPFS
+ automatically. If you already have the files uploaded elsewhere, you
+ can specify the IPFS or HTTP(s) links in the{" "}
+ and/or{" "}
+ {" "}
+ {tab === "csv" ? "columns" : "properties"}. instead of uploading the
+ assets and just upload a single {tab === "csv" ? "CSV" : "JSON"}{" "}
+ file.
+
+
+
+
+
+
+ You can also add ,{" "}
+ and{" "}
+ {" "}
+ {tab === "csv" ? "columns" : "properties"} to set the price and
+ supply of the NFT. If you don't specify it in{" "}
+ {tab === "csv" ? "CSV" : "JSON"} file, you will be prompted to set
+ it in the next step. To set the price in native token, leave the the{" "}
+ {" "}
+ {tab === "csv" ? "column" : "property"} empty. To set the price in
+ ERC20 token - specify the token address in the{" "}
+ column.{" "}
+
+
+
+
+
+ );
+}
+
+function Section(props: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {props.title}
+
+
+ {props.children}
+
+
+ );
+}
+
+const csv_example_basic = `\
+name,description,background_color,foo,bar,bazz
+Token 0 Name,Token 0 Description,#0098EE,value1,value2,value3
+Token 1 Name,Token 1 Description,#0098EE,value1,value2,value3
+`;
+
+const csv_example_price_supply = `\
+name,description,background_color,foo,bar,bazz,price_amount,price_currency,supply
+Token 0 Name,Token 0 Description,#0098EE,value1,value2,value3,0.1,,1
+Token 1 Name,Token 1 Description,#0098EE,value1,value2,value3,0.2,0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48,10
+`;
+
+const json_example_basic = `\
+[
+ {
+ "name": "Token 0 Name",
+ "description": "Token 0 Description",
+ "background_color": "#0098EE",
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ },
+ {
+ "name": "Token 1 Name",
+ "description": "Token 1 Description",
+ "background_color": "#0098EE",
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ }
+]
+`;
+
+const json_example_price_supply = `\
+[
+ {
+ "name": "Token 0 Name",
+ "description": "Token 0 Description",
+ "price_amount": 0.1,
+ "price_currency": "",
+ "supply": 1,
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ },
+ {
+ "name": "Token 1 Name",
+ "description": "Token 1 Description",
+ "price_amount": 0.2,
+ "price_currency": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
+ "supply": 10,
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ }
+]
+`;
+
+const csv_with_image_link_example = `\
+name,description,image,animation_url,background_color,foo,bar,bazz
+Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,#0098EE,value1,value2,value3
+Token 1 Name,Token 1 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,#0098EE,value1,value2,value3
+`;
+
+const json_with_image_link_example = `\
+[
+ {
+ "name": "Token 0 Name",
+ "description": "Token 0 Description",
+ "image": "ipfs://ipfsHash/0",
+ "animation_url": "ipfs://ipfsHash/0",
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ },
+ {
+ "name": "Token 1 Name",
+ "description": "Token 1 Description",
+ "image": "ipfs://ipfsHash/0",
+ "animation_url": "ipfs://ipfsHash/0",
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ }
+]
+`;
+
+const csv_with_image_number_example = `\
+name,description,image,animation_url,background_color,foo,bar,bazz
+Token 0 Name,Token 0 Description,0.png,0.mp4,#0098EE,value1,value2,value3
+Token 1 Name,Token 1 Description,1.png,1.mp4,#0098EE,value1,value2,value3
+`;
+
+const json_with_image_number_example = `\
+[
+ {
+ "name": "Token 0 Name",
+ "description": "Token 0 Description",
+ "image": "0.png",
+ "animation_url": "0.mp4",
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ },
+ {
+ "name": "Token 1 Name",
+ "description": "Token 1 Description",
+ "image": "1.png",
+ "animation_url": "1.mp4",
+ "attributes": [
+ {
+ "trait_type": "foo",
+ "value": "value1"
+ },
+ {
+ "trait_type": "bar",
+ "value": "value2"
+ },
+ {
+ "trait_type": "bazz",
+ "value": "value3"
+ }
+ ]
+ }
+]
+`;
+
+function ExampleCode(props: {
+ code: string;
+ lang: "csv" | "json";
+ fileNameWithExtension: string;
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-nfts.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-nfts.tsx
new file mode 100644
index 00000000000..59f4332d8ce
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-nfts.tsx
@@ -0,0 +1,544 @@
+import { TokenSelector } from "@/components/blocks/TokenSelector";
+import { DropZone } from "@/components/blocks/drop-zone/drop-zone";
+import { PaginationButtons } from "@/components/pagination-buttons";
+import { DynamicHeight } from "@/components/ui/DynamicHeight";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { DecimalInput } from "@/components/ui/decimal-input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
+import {
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ ImageOffIcon,
+ RotateCcwIcon,
+ TagIcon,
+} from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import {
+ NATIVE_TOKEN_ADDRESS,
+ type ThirdwebClient,
+ getAddress,
+} from "thirdweb";
+import { MediaRenderer } from "thirdweb/react";
+import { FilePreview } from "../../../_common/file-preview";
+import { nftWithPriceSchema } from "../schema";
+import { BatchUploadInstructions } from "./batch-upload-instructions";
+import {
+ type NFTMetadataWithPrice,
+ type ProcessBatchUploadFilesResult,
+ processBatchUploadFiles,
+} from "./process-files";
+
+export function BatchUploadNFTs(props: {
+ client: ThirdwebClient;
+ results: ProcessBatchUploadFilesResult | null;
+ setResults: (results: ProcessBatchUploadFilesResult | null) => void;
+ chainId: number;
+ onNext: () => void;
+ onPrev: () => void;
+}) {
+ return (
+
+ {!props.results || props.results.type === "error" ? (
+
+
+
{
+ try {
+ const results = await processBatchUploadFiles(files);
+ props.setResults(results);
+ } catch (e) {
+ console.error(e);
+ props.setResults({
+ type: "error",
+ error: e instanceof Error ? e.message : "Unknown error",
+ });
+ }
+ }}
+ accept={undefined}
+ title={
+ props.results?.type === "error"
+ ? "Invalid files"
+ : "Upload Files"
+ }
+ description={
+ props.results?.type === "error"
+ ? props.results.error
+ : "Drag and drop the files or folder"
+ }
+ resetButton={{
+ label: "Remove files",
+ onClick: () => {
+ props.setResults(null);
+ },
+ }}
+ className="bg-background py-20"
+ />
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
{
+ const data =
+ typeof dataOrFn === "function"
+ ? dataOrFn(
+ props.results?.type === "data" ? props.results.data : [],
+ )
+ : dataOrFn;
+
+ props.setResults({
+ type: "data",
+ data,
+ });
+ }}
+ onReset={() => {
+ props.setResults(null);
+ }}
+ client={props.client}
+ />
+ )}
+
+ );
+}
+
+const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
+
+function BatchUploadResultsTable(props: {
+ data: NFTMetadataWithPrice[];
+ setData: React.Dispatch>;
+ onReset: () => void;
+ client: ThirdwebClient;
+ chainId: number;
+ onNext: () => void;
+ onPrev: () => void;
+}) {
+ const { data, setData, onReset, client, onNext, onPrev } = props;
+ const imageFallback = (
+
+
+
+ );
+
+ const [batchMode, setBatchMode] = useState(false);
+ const [page, setPage] = useState(1);
+ const pageSize = 3;
+ const itemsToShow = data.slice((page - 1) * pageSize, page * pageSize);
+ const totalPages = Math.ceil(data.length / pageSize);
+
+ return (
+
+
+
+
+
+
Global Settings
+
+ Update price, currency, and supply for all NFTs at once.
+
+
+
+
+
+
+ {batchMode && (
+
+ )}
+
+
+
+
+
+
+
+ NFT
+ Price
+ Supply
+
+
+
+ {itemsToShow.map((item, paginatedIndex) => {
+ const itemIndex = paginatedIndex + (page - 1) * pageSize;
+
+ return (
+
+ {/* Nft */}
+
+
+
+ {item.image instanceof File ? (
+
+ ) : typeof item.image === "string" ? (
+
+ ) : (
+ imageFallback
+ )}
+
+ {!!item.animation_url && (
+
+ {typeof item.animation_url === "string" ? (
+
+ ) : item.animation_url instanceof File ? (
+
+ ) : (
+ imageFallback
+ )}
+
+ )}
+
+
+
+
+
+ {item.name}
+
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+ {item.background_color && (
+
+
+
+ {item.background_color}
+
+
+ )}
+
+ {Array.isArray(item.attributes) && (
+
+ {item.attributes.map((property: unknown) => {
+ if (
+ typeof property === "object" &&
+ property !== null &&
+ "trait_type" in property &&
+ "value" in property &&
+ typeof property.trait_type === "string" &&
+ typeof property.value === "string"
+ ) {
+ return (
+
+
+
+ {property.trait_type}
+
+
+ {property.value}
+
+
+ );
+ }
+ })}
+
+ )}
+
+ {item.external_url &&
+ typeof item.external_url === "string" && (
+
+ )}
+
+
+
+
+ {/* Price Amount */}
+
+
+ {
+ setData((v) => {
+ const newData = [...v];
+ newData[itemIndex] = {
+ ...newData[itemIndex],
+ price_amount: value,
+ price_currency: item.price_currency,
+ supply: item.supply,
+ };
+ return newData;
+ });
+ }}
+ />
+
+ {
+ setData((v) => {
+ const newData = [...v];
+ newData[itemIndex] = {
+ ...newData[itemIndex],
+ price_currency: token.address,
+ price_amount: item.price_amount,
+ supply: item.supply,
+ };
+ return newData;
+ });
+ }}
+ client={props.client}
+ chainId={props.chainId}
+ />
+
+
+
+ {/* Supply */}
+
+
+
{
+ setData((v) => {
+ const newData = [...v];
+ newData[itemIndex] = {
+ ...newData[itemIndex],
+ supply: value,
+ price_currency: item.price_currency,
+ price_amount: item.price_amount,
+ };
+ return newData;
+ });
+ }}
+ />
+
+ {item.supply === "0" && (
+ Invalid Supply
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ {totalPages > 1 && (
+
+ )}
+
+
+
+
+ {/* back */}
+
+
+
+
+
+
+ );
+}
+
+function BatchUpdateFieldset(props: {
+ data: NFTMetadataWithPrice[];
+ setData: React.Dispatch>;
+ client: ThirdwebClient;
+ chainId: number;
+}) {
+ const { data, setData, client, chainId } = props;
+
+ const isAllPriceAmountSame = data.every(
+ (item) => item.price_amount === data[0]?.price_amount,
+ );
+ const isAllPriceCurrencySame = data.every(
+ (item) => item.price_currency === data[0]?.price_currency,
+ );
+ const isAllSupplySame = data.every((item) => item.supply === data[0]?.supply);
+
+ const globalSupply = isAllSupplySame ? data[0]?.supply || "" : "";
+ const globalPriceAmount = isAllPriceAmountSame
+ ? data[0]?.price_amount || ""
+ : "";
+
+ const globalPriceCurrency = isAllPriceCurrencySame
+ ? data[0]?.price_currency
+ : undefined;
+
+ return (
+
+
+
+
+
+ {
+ setData((v) => {
+ return v.map((item) => ({
+ ...item,
+ price_amount: value,
+ }));
+ });
+ }}
+ />
+
+ {
+ setData((v) => {
+ return v.map((item) => ({
+ ...item,
+ price_currency: token.address,
+ }));
+ });
+ }}
+ client={client}
+ chainId={chainId}
+ />
+
+
+
+
+
+
{
+ setData((v) => {
+ return v.map((item) => ({
+ ...item,
+ supply: value,
+ }));
+ });
+ }}
+ />
+
+ {globalSupply === "0" && (
+ Invalid Supply
+ )}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/process-files.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/process-files.ts
new file mode 100644
index 00000000000..7b9269cc328
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/process-files.ts
@@ -0,0 +1,327 @@
+import Papa from "papaparse";
+import { NATIVE_TOKEN_ADDRESS, getAddress, isAddress } from "thirdweb";
+import type { NFTInput } from "thirdweb/utils";
+import z from "zod";
+import {
+ csvMimeTypes,
+ jsonMimeTypes,
+} from "../../../../../../../../../../../utils/batch";
+
+const transformHeader = (h: string) => {
+ const headersToTransform = [
+ "name",
+ "description",
+ "image",
+ "animation_url",
+ "external_url",
+ "background_color",
+ "price_amount",
+ "price_currency",
+ "supply",
+ ];
+
+ if (headersToTransform.includes(h.trim().toLowerCase())) {
+ return h.trim().toLowerCase();
+ }
+ return h.trim();
+};
+
+const getAcceptedFiles = async (acceptedFiles: File[]) => {
+ const organizedFiles: {
+ csv: File | undefined;
+ json: File | undefined;
+ images: File[];
+ otherAssets: File[];
+ } = {
+ csv: undefined,
+ json: undefined,
+ images: [],
+ otherAssets: [],
+ };
+
+ for (const file of acceptedFiles) {
+ if (jsonMimeTypes.includes(file.type) || file.name.endsWith(".json")) {
+ organizedFiles.json = file;
+ } else if (csvMimeTypes.includes(file.type) || file.name.endsWith(".csv")) {
+ organizedFiles.csv = file;
+ } else if (file.type.includes("image/")) {
+ organizedFiles.images.push(file);
+ } else {
+ organizedFiles.otherAssets.push(file);
+ }
+ }
+
+ return organizedFiles;
+};
+
+export type NFTMetadataWithPrice = {
+ name?: string;
+ description?: string;
+ image?: File | string;
+ animation_url?: File | string;
+ external_url?: string;
+ background_color?: string;
+ price_amount: string;
+ price_currency: string;
+ supply: string;
+ attributes?: Array<{ trait_type: string; value: string }>;
+};
+
+const handleCSV = (params: {
+ csvData: CSVRowData[];
+ imageFiles: File[];
+ otherAssets: File[];
+}): NFTMetadataWithPrice[] => {
+ const { csvData, imageFiles, otherAssets } = params;
+ const isImageMapped = csvData.some((row) =>
+ imageFiles.find((x) => x.name === row.image),
+ );
+
+ const isAnimationUrlMapped = csvData.some((row) =>
+ otherAssets.find((x) => x.name === row.animation_url),
+ );
+
+ return csvData.map((row, index) => {
+ const {
+ name,
+ description,
+ image,
+ animation_url,
+ external_url,
+ background_color,
+ price_amount,
+ price_currency,
+ supply,
+ ...propertiesObj
+ } = row;
+
+ function getAttributes() {
+ const attributes: NFTMetadataWithPrice["attributes"] = [];
+ for (const key in propertiesObj) {
+ if (propertiesObj[key] && typeof propertiesObj[key] === "string") {
+ attributes.push({
+ trait_type: key,
+ value: propertiesObj[key],
+ });
+ }
+ }
+ return attributes;
+ }
+
+ const nft: NFTMetadataWithPrice = {
+ name: name,
+ description: description,
+ external_url,
+ background_color,
+ attributes: getAttributes(),
+ image:
+ imageFiles.find((img) => img?.name === image) ||
+ (!isImageMapped && imageFiles[index]) ||
+ image ||
+ undefined,
+ animation_url:
+ otherAssets.find((asset) => asset?.name === animation_url) ||
+ (!isAnimationUrlMapped && otherAssets[index]) ||
+ animation_url,
+ price_amount: price_amount || "1",
+ price_currency: price_currency || getAddress(NATIVE_TOKEN_ADDRESS),
+ supply: supply || "1",
+ };
+
+ return nft;
+ });
+};
+
+function handleJson(params: {
+ jsonData: NFTInput[];
+ imageFiles: File[];
+ otherAssets: File[];
+}): NFTMetadataWithPrice[] {
+ const { jsonData, imageFiles, otherAssets } = params;
+
+ const isImageMapped = jsonData.some((row) =>
+ imageFiles.find((img) => img?.name === row.image),
+ );
+ const isAnimationUrlMapped = jsonData.some((row) =>
+ otherAssets.find((x) => x.name === row.animation_url),
+ );
+
+ return jsonData.map((_nft: unknown, index: number) => {
+ const nft = nftWithPriceInputJsonSchema.parse(_nft);
+
+ const nftWithPrice = {
+ ...nft,
+ image:
+ imageFiles.find((img) => img?.name === nft?.image) ||
+ (!isImageMapped && imageFiles[index]) ||
+ nft.image ||
+ undefined,
+ animation_url:
+ otherAssets.find((x) => x.name === nft?.animation_url) ||
+ (!isAnimationUrlMapped && otherAssets[index]) ||
+ nft.animation_url ||
+ undefined,
+ price_amount: nft.price_amount || "1",
+ price_currency: nft.price_currency || getAddress(NATIVE_TOKEN_ADDRESS),
+ supply: nft.supply || "1",
+ };
+
+ return nftWithPrice;
+ });
+}
+
+export type ProcessBatchUploadFilesResult =
+ | {
+ type: "data";
+ data: NFTMetadataWithPrice[];
+ }
+ | {
+ type: "error";
+ error: string;
+ };
+
+const priceAmountSchema = z
+ .string()
+ .optional()
+ .refine(
+ (val) => {
+ if (!val || val.trim() === "") {
+ return true;
+ }
+ const num = Number(val);
+ if (Number.isNaN(num)) {
+ return false;
+ }
+
+ return num >= 0;
+ },
+ {
+ message: "Price amount must be a number greater than or equal to 0",
+ },
+ );
+
+const supplySchema = z
+ .string()
+ .optional()
+ .refine(
+ (val) => {
+ if (!val || val.trim() === "") {
+ return true;
+ }
+ const num = Number(val);
+ if (Number.isNaN(num)) {
+ return false;
+ }
+ return num > 0;
+ },
+ {
+ message: "Supply must be a number greater than 0",
+ },
+ );
+
+const nftWithPriceInputJsonSchema = z.object({
+ name: z.string(),
+ description: z.string().optional(),
+ image: z.string().or(z.instanceof(File)).optional(),
+ animation_url: z.string().or(z.instanceof(File)).optional(),
+ external_url: z.string().optional(),
+ background_color: z.string().optional(),
+ price_amount: priceAmountSchema,
+ price_currency: z
+ .string()
+ .refine((value) => {
+ if (!value) {
+ return true;
+ }
+ return isAddress(value);
+ })
+ .optional(),
+ supply: supplySchema,
+ attributes: z
+ .array(z.object({ trait_type: z.string(), value: z.string() }))
+ .optional(),
+});
+
+const nftWithPriceInputCsvSchema = z.object({
+ name: z.string(),
+ description: z.string().optional(),
+ image: z.string().or(z.instanceof(File)).optional(),
+ animation_url: z.string().or(z.instanceof(File)).optional(),
+ external_url: z.string().optional(),
+ background_color: z.string().optional(),
+ price_amount: priceAmountSchema,
+ price_currency: z.string().optional(),
+ supply: supplySchema,
+});
+
+type CSVRowData = z.infer &
+ Record;
+
+export async function processBatchUploadFiles(
+ files: File[],
+): Promise {
+ const { csv, json, images, otherAssets } = await getAcceptedFiles(files);
+
+ if (json) {
+ const jsonData = JSON.parse(await json.text());
+ if (Array.isArray(jsonData)) {
+ return {
+ type: "data",
+ data: handleJson({ jsonData, imageFiles: images, otherAssets }),
+ };
+ } else {
+ return {
+ type: "error",
+ error: "Invalid JSON format",
+ };
+ }
+ } else if (csv) {
+ return new Promise((res, rej) => {
+ Papa.parse(csv, {
+ header: true,
+ transformHeader,
+ complete: (results) => {
+ try {
+ const validRows: CSVRowData[] = [];
+
+ for (let i = 0; i < results.data.length; i++) {
+ if (!results.errors.find((e) => e.row === i)) {
+ const result = results.data[i];
+ const parsed = nftWithPriceInputCsvSchema
+ .passthrough()
+ .parse(result);
+
+ if (parsed) {
+ validRows.push(parsed);
+ }
+ }
+ }
+
+ if (validRows.length > 0) {
+ res({
+ type: "data",
+ data: handleCSV({
+ csvData: validRows,
+ imageFiles: images,
+ otherAssets,
+ }),
+ });
+ } else {
+ res({
+ type: "error",
+ error: "No valid CSV data found",
+ });
+ }
+ } catch (e) {
+ rej(e);
+ }
+ },
+ });
+ });
+ } else {
+ return {
+ type: "error",
+ error: "No valid files found. Please upload a '.csv' or '.json' file.",
+ };
+ }
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/schema.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/schema.ts
new file mode 100644
index 00000000000..7d6868c6bb4
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/schema.ts
@@ -0,0 +1,68 @@
+import { isAddress } from "thirdweb";
+import z from "zod";
+
+const priceAmountSchema = z.string().refine(
+ (val) => {
+ const num = Number(val);
+ if (Number.isNaN(num)) {
+ return false;
+ }
+
+ return num >= 0;
+ },
+ {
+ message: "Price amount must be a number greater than or equal to 0",
+ },
+);
+
+const supplySchema = z.string().refine(
+ (val) => {
+ const num = Number(val);
+ if (Number.isNaN(num)) {
+ return false;
+ }
+ return num > 0;
+ },
+ {
+ message: "Supply must be a number greater than 0",
+ },
+);
+
+export const nftWithPriceSchema = z.object({
+ name: z.string().min(1, { message: "Name is required" }),
+ description: z.string().optional(),
+ image: z.string().or(z.instanceof(File)).optional(),
+ animation_url: z.string().or(z.instanceof(File)).optional(),
+ external_url: z.string().or(z.instanceof(File)).optional(),
+ background_color: z
+ .string()
+ .optional()
+ .refine((val) => !val || /^#[0-9A-Fa-f]{6}$/.test(val), {
+ message: "Must be a valid hex color (e.g., #FF0000)",
+ }),
+ price_amount: priceAmountSchema,
+ price_currency: z.string().refine(
+ (value) => {
+ if (!value) {
+ return true;
+ }
+ return isAddress(value);
+ },
+ {
+ message: "Must be a valid token contract address",
+ },
+ ),
+ supply: supplySchema,
+ attributes: z
+ .array(z.object({ trait_type: z.string(), value: z.string() }))
+ .transform((value) => {
+ if (!value) {
+ return value;
+ }
+
+ return value.filter((item) => {
+ return item.trait_type && item.value;
+ });
+ })
+ .optional(),
+});
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/attributes.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/attributes.tsx
new file mode 100644
index 00000000000..e1cb7c24711
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/attributes.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { PlusIcon, Trash2Icon } from "lucide-react";
+import { type UseFormReturn, useFieldArray } from "react-hook-form";
+
+type WithAttributes = {
+ attributes?: {
+ trait_type: string;
+ value: string;
+ }[];
+};
+
+export function AttributesFieldset(props: {
+ form: UseFormReturn;
+}) {
+ // T contains all properties of WithSocialUrls, so this is ok
+ const form = props.form as unknown as UseFormReturn;
+
+ const { fields, append, remove } = useFieldArray({
+ name: "attributes",
+ control: form.control,
+ });
+
+ return (
+
+
Attributes
+
+ {fields.length > 0 && (
+
+ {fields.map((field, index) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/single-upload-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/single-upload-nft.tsx
new file mode 100644
index 00000000000..b9b1894990b
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/single-upload-nft.tsx
@@ -0,0 +1,339 @@
+"use client";
+
+import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
+import { TokenSelector } from "@/components/blocks/TokenSelector";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { DecimalInput } from "@/components/ui/decimal-input";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { FileInput } from "components/shared/FileInput";
+import { ArrowLeftIcon, ArrowRightIcon, AsteriskIcon } from "lucide-react";
+import { useForm } from "react-hook-form";
+import type { ThirdwebClient } from "thirdweb";
+import {
+ getUploadedNFTMediaMeta,
+ handleNFTMediaUpload,
+} from "../../../../../../../../../(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/handleNFTMediaUpload";
+import type { NFTMetadataWithPrice } from "../batch-upload/process-files";
+import { nftWithPriceSchema } from "../schema";
+import { AttributesFieldset } from "./attributes";
+
+export function SingleUploadNFT(props: {
+ client: ThirdwebClient;
+ onNext: () => void;
+ onPrev: () => void;
+ chainId: number;
+ nftData: NFTMetadataWithPrice | null;
+ setNFTData: (nftData: NFTMetadataWithPrice) => void;
+}) {
+ const form = useForm({
+ resolver: zodResolver(nftWithPriceSchema),
+ values: props.nftData || undefined,
+ defaultValues: {
+ image: undefined,
+ name: "",
+ description: "",
+ attributes: [{ trait_type: "", value: "" }],
+ },
+ reValidateMode: "onChange",
+ });
+
+ function handlePrev() {
+ props.setNFTData(form.getValues()); // save before going back
+ props.onPrev();
+ }
+
+ const setFile = (file: File) => {
+ handleNFTMediaUpload({ file, form });
+ };
+
+ const { media, image, mediaFileError, showCoverImageUpload, external_url } =
+ getUploadedNFTMediaMeta(form);
+
+ return (
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.stories.tsx
new file mode 100644
index 00000000000..faa710dd2f6
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.stories.tsx
@@ -0,0 +1,119 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { useState } from "react";
+import { storybookThirdwebClient } from "stories/utils";
+import { NATIVE_TOKEN_ADDRESS, getAddress } from "thirdweb";
+import { ThirdwebProvider } from "thirdweb/react";
+import type { NFTMetadataWithPrice } from "./batch-upload/process-files";
+import { type NFTData, UploadNFTsFieldset } from "./upload-nfts";
+
+const meta = {
+ title: "Asset/CreateNFT/UploadNFTsFieldset",
+ component: Variant,
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+function getMockNFTData(params?: Partial) {
+ const mockNFTData: NFTMetadataWithPrice = {
+ name: "Test NFT",
+ description: "Test Description",
+ image: "https://picsum.photos/500/500",
+ price_amount: "0.1",
+ price_currency: getAddress(NATIVE_TOKEN_ADDRESS),
+ supply: "1",
+ attributes: [
+ {
+ trait_type: "test",
+ value: "value",
+ },
+ ],
+ ...params,
+ };
+
+ return mockNFTData;
+}
+
+export const MultipleNFTsNull: Story = {
+ args: {
+ nftData: {
+ type: "multiple",
+ nfts: null,
+ },
+ },
+};
+
+export const SingleNFTNull: Story = {
+ args: {
+ nftData: {
+ type: "single",
+ nft: null,
+ },
+ },
+};
+
+export const MultipleNFTsSet: Story = {
+ args: {
+ nftData: {
+ type: "multiple",
+ nfts: {
+ type: "data",
+ data: Array(5)
+ .fill(null)
+ .map((_, i) =>
+ getMockNFTData({
+ name: `Test NFT ${i + 1}`,
+ price_amount: Math.random().toFixed(2),
+ }),
+ ),
+ },
+ },
+ },
+};
+
+export const MultipleNFTsError: Story = {
+ args: {
+ nftData: {
+ type: "multiple",
+ nfts: {
+ type: "error",
+ error: "This is some error message",
+ },
+ },
+ },
+};
+
+export const SingleNFTSet: Story = {
+ args: {
+ nftData: {
+ type: "single",
+ nft: getMockNFTData(),
+ },
+ },
+};
+
+function Variant(props: {
+ nftData: NFTData;
+}) {
+ const [nftData, setNFTData] = useState(props.nftData);
+
+ return (
+ console.log("next")}
+ onPrev={() => console.log("prev")}
+ nftData={nftData}
+ setNFTData={setNFTData}
+ />
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.tsx
new file mode 100644
index 00000000000..0cc785654fa
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.tsx
@@ -0,0 +1,117 @@
+import { TabButtons } from "@/components/ui/tabs";
+import { useTrack } from "hooks/analytics/useTrack";
+import type { ThirdwebClient } from "thirdweb";
+import { StepCard } from "../../_common/step-card";
+import { getStepCardTrackingData } from "../../token/_common/tracking";
+import { nftCreationPages } from "../_common/pages";
+import { BatchUploadNFTs } from "./batch-upload/batch-upload-nfts";
+import type {
+ NFTMetadataWithPrice,
+ ProcessBatchUploadFilesResult,
+} from "./batch-upload/process-files";
+import { SingleUploadNFT } from "./single-upload/single-upload-nft";
+
+export type NFTData =
+ | {
+ type: "multiple";
+ nfts: ProcessBatchUploadFilesResult | null;
+ }
+ | {
+ type: "single";
+ nft: NFTMetadataWithPrice | null;
+ };
+
+export function UploadNFTsFieldset(props: {
+ client: ThirdwebClient;
+ chainId: number;
+ onNext: () => void;
+ onPrev: () => void;
+ nftData: NFTData;
+ setNFTData: (nftData: NFTData) => void;
+}) {
+ const trackEvent = useTrack();
+
+ function handleNextClick() {
+ trackEvent(
+ getStepCardTrackingData({
+ step: nftCreationPages["upload-assets"],
+ click: "next",
+ contractType: "NFTCollection",
+ }),
+ );
+ props.onNext();
+ }
+
+ function handlePrevClick() {
+ trackEvent(
+ getStepCardTrackingData({
+ step: nftCreationPages["upload-assets"],
+ click: "prev",
+ contractType: "NFTCollection",
+ }),
+ );
+ props.onPrev();
+ }
+
+ return (
+
+
+ props.setNFTData({
+ type: "single",
+ nft: null,
+ }),
+ isActive: props.nftData.type === "single",
+ },
+ {
+ name: "Create Multiple",
+ onClick: () => props.setNFTData({ type: "multiple", nfts: null }),
+ isActive: props.nftData.type === "multiple",
+ },
+ ]}
+ />
+
+ {props.nftData.type === "multiple" && (
+
+ props.setNFTData({
+ type: "multiple",
+ nfts: results,
+ })
+ }
+ chainId={props.chainId}
+ />
+ )}
+
+ {props.nftData.type === "single" && (
+
+ props.setNFTData({
+ type: "single",
+ nft,
+ })
+ }
+ />
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/form.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/_common/form.ts
similarity index 71%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/form.ts
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/_common/form.ts
index a15598059b1..74ee17abc25 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/form.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/_common/form.ts
@@ -1,20 +1,6 @@
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();
+import { addressSchema, socialUrlsSchema } from "../../_common/schema";
export const tokenInfoFormSchema = z.object({
// info fieldset
@@ -25,25 +11,8 @@ export const tokenInfoFormSchema = z.object({
.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",
- },
- ),
- }),
- ),
+ image: z.instanceof(File).optional(),
+ socialUrls: socialUrlsSchema,
});
export const tokenDistributionFormSchema = z.object({
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/tracking.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/_common/tracking.ts
similarity index 93%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/tracking.ts
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/_common/tracking.ts
index 412058fa8c3..76a4c6d3e4c 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/tracking.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/_common/tracking.ts
@@ -52,7 +52,7 @@ export function getTokenStepTrackingData(
}
// example: asset.launch.attempt
-export function getLaunchTrackingData(
+export function getTokenLaunchTrackingData(
params: {
chainId: number;
airdropEnabled: boolean;
@@ -85,13 +85,14 @@ export function getLaunchTrackingData(
// example: asset.info-page.next-click
export function getStepCardTrackingData(params: {
- step: "info" | "distribution" | "launch";
+ step: string;
click: "prev" | "next";
+ contractType: "DropERC20" | "NFTCollection";
}) {
return {
category: "asset",
action: `${params.step}-page`,
label: `${params.click}-click`,
- contractType: "DropERC20",
+ contractType: params.contractType,
};
}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page-impl.tsx
similarity index 95%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page-impl.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page-impl.tsx
index c4e934075ff..dc97420baeb 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page-impl.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page-impl.tsx
@@ -26,14 +26,15 @@ import {
transferBatch,
} from "thirdweb/extensions/erc20";
import { useActiveAccount } from "thirdweb/react";
-import { pollWithTimeout } from "../../../../../../../../utils/pollWithTimeout";
-import { useAddContractToProject } from "../../hooks/project-contracts";
-import { CreateTokenAssetPageUI } from "./create-token-page.client";
-import type { CreateAssetFormValues } from "./form";
+import { parseError } from "utils/errorParser";
+import { pollWithTimeout } from "utils/pollWithTimeout";
+import { useAddContractToProject } from "../../../hooks/project-contracts";
+import type { CreateAssetFormValues } from "./_common/form";
import {
getTokenDeploymentTrackingData,
getTokenStepTrackingData,
-} from "./tracking";
+} from "./_common/tracking";
+import { CreateTokenAssetPageUI } from "./create-token-page.client";
export function CreateTokenAssetPage(props: {
accountAddress: string;
@@ -136,12 +137,15 @@ export function CreateTokenAssetPage(props: {
contractAddress: contractAddress,
};
} catch (e) {
+ const parsedError = parseError(e);
+ const errorMessage =
+ typeof parsedError === "string" ? parsedError : "Unknown error";
trackEvent(
getTokenStepTrackingData({
action: "deploy",
chainId: Number(formValues.chain),
status: "error",
- errorMessage: e instanceof Error ? e.message : "Unknown error",
+ errorMessage,
}),
);
@@ -149,7 +153,7 @@ export function CreateTokenAssetPage(props: {
getTokenDeploymentTrackingData({
type: "error",
chainId: Number(formValues.chain),
- errorMessage: e instanceof Error ? e.message : "Unknown error",
+ errorMessage,
}),
);
throw e;
@@ -409,7 +413,7 @@ export function CreateTokenAssetPage(props: {
projectSlug={props.projectSlug}
onLaunchSuccess={() => {
revalidatePathAction(
- `/team/${props.teamId}/project/${props.projectId}/assets`,
+ `/team/${props.teamSlug}/project/${props.projectId}/assets`,
"page",
);
}}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page.client.tsx
similarity index 97%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.client.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page.client.tsx
index 4e8948cd5ee..0225428e4db 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page.client.tsx
@@ -8,16 +8,16 @@ import {
type ThirdwebClient,
getAddress,
} from "thirdweb";
-import { TokenDistributionFieldset } from "./distribution/token-distribution";
import {
type CreateAssetFormValues,
type TokenDistributionFormValues,
type TokenInfoFormValues,
tokenDistributionFormSchema,
tokenInfoFormSchema,
-} from "./form";
+} from "./_common/form";
+import { TokenDistributionFieldset } from "./distribution/token-distribution";
import { LaunchTokenStatus } from "./launch/launch-token";
-import { TokenInfoFieldset } from "./token-info-fieldset";
+import { TokenInfoFieldset } from "./token-info/token-info-fieldset";
export type CreateTokenFunctions = {
deployContract: (values: CreateAssetFormValues) => Promise<{
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page.stories.tsx
similarity index 100%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.stories.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/create-token-page.stories.tsx
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-airdrop.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/distribution/token-airdrop.tsx
similarity index 76%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-airdrop.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/distribution/token-airdrop.tsx
index 0c485759c3b..59102c6ef42 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-airdrop.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/distribution/token-airdrop.tsx
@@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element */
"use client";
+import { DropZone } from "@/components/blocks/drop-zone/drop-zone";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
@@ -27,18 +28,17 @@ import { Textarea } from "@/components/ui/textarea";
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 { ThirdwebClient } from "thirdweb";
-import type { TokenDistributionForm } from "../form";
+import { DownloadFileButton } from "../../_common/download-file-button";
+import type { TokenDistributionForm } from "../_common/form";
type AirdropAddressInput = {
address: string;
@@ -240,16 +240,7 @@ const AirdropUpload: React.FC = ({
}) => {
const [textInput, setTextInput] = useState("");
- const {
- normalizeQuery,
- getInputProps,
- getRootProps,
- rawData,
- noCsv,
- reset,
- removeInvalid,
- processData,
- } = useCsvUpload({
+ const csvUpload = useCsvUpload({
csvParser: (items: AirdropAddressInput[]) => {
return items
.map(({ address, quantity }) => ({
@@ -261,17 +252,15 @@ const AirdropUpload: React.FC = ({
client,
});
- const normalizeData = normalizeQuery.data;
+ const normalizeData = csvUpload.normalizeQuery.data;
- // Handle text input - directly process the parsed data
const handleTextSubmit = () => {
if (!textInput.trim()) return;
-
const parsedData = parseTextInput(textInput);
- processData(parsedData);
+ csvUpload.processData(parsedData);
};
- if (!normalizeData && rawData.length > 0) {
+ if (csvUpload.normalizeQuery.isPending) {
return (
@@ -293,7 +282,7 @@ const AirdropUpload: React.FC
= ({
};
const handleReset = () => {
- reset();
+ csvUpload.reset();
setTextInput("");
};
@@ -301,9 +290,9 @@ const AirdropUpload: React.FC = ({
{normalizeData &&
normalizeData.result.length > 0 &&
- rawData.length > 0 ? (
+ csvUpload.rawData.length > 0 ? (
- {normalizeQuery.data.invalidFound && (
+ {csvUpload.normalizeQuery.data.invalidFound && (
Invalid addresses found. Please remove them before continuing.
@@ -317,10 +306,10 @@ const AirdropUpload: React.FC
= ({
Reset
- {normalizeQuery.data.invalidFound ? (
+ {csvUpload.normalizeQuery.data.invalidFound ? (
) : (
-
+
{/* CSV Upload Section - First */}
-
-
-
-
- {!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
-
-
-
-
- )}
-
-
-
+
{/* Divider */}
@@ -418,7 +369,8 @@ const AirdropUpload: React.FC
= ({
);
}
-function DownloadExampleCSVButton() {
- return (
-
- );
-}
+const exampleCSV = `\
+address,quantity
+0x000000000000000000000000000000000000dEaD,2
+thirdweb.eth,1`;
function AirdropTable(props: {
data: AirdropAddressInput[];
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-distribution.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/distribution/token-distribution.tsx
similarity index 95%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-distribution.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/distribution/token-distribution.tsx
index b35585c9e89..a2510985fcc 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-distribution.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/distribution/token-distribution.tsx
@@ -8,11 +8,11 @@ import {
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { ThirdwebClient } from "thirdweb";
-import { StepCard } from "../create-token-card";
+import { StepCard } from "../../_common/step-card";
import type {
TokenDistributionForm,
TokenDistributionFormValues,
-} from "../form";
+} from "../_common/form";
import { TokenAirdropSection } from "./token-airdrop";
import { TokenSaleSection } from "./token-sale";
@@ -33,7 +33,10 @@ export function TokenDistributionFieldset(props: {
+ }
+ />
@@ -249,7 +295,7 @@ export function LaunchTokenStatus(props: {
)}
-
+
@@ -288,35 +334,6 @@ const compactNumberFormatter = new Intl.NumberFormat("en-US", {
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;
@@ -335,24 +352,3 @@ function OverviewFieldValue(props: {
}) {
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]/(sidebar)/assets/create/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/page.tsx
similarity index 50%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/page.tsx
index d27b6f560b7..60244217977 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/page.tsx
@@ -1,21 +1,13 @@
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";
+} from "../../../../../../../api/lib/getAuthToken";
+import { loginRedirect } from "../../../../../../../login/loginRedirect";
+import { CreateAssetPageHeader } from "../_common/PageHeader";
import { CreateTokenAssetPage } from "./create-token-page-impl";
export default async function Page(props: {
@@ -32,7 +24,7 @@ export default async function Page(props: {
if (!authToken || !accountAddress) {
loginRedirect(
- `/team/${params.team_slug}/${params.project_slug}/assets/create`,
+ `/team/${params.team_slug}/${params.project_slug}/assets/create/token`,
);
}
@@ -51,9 +43,12 @@ export default async function Page(props: {
return (
-
);
}
-
-function PageHeader(props: {
- teamSlug: string;
- projectSlug: string;
-}) {
- return (
-
-
-
-
-
-
-
- Assets
-
-
-
-
-
- Create Coin
-
-
-
-
-
-
-
-
- Create Coin
-
-
- Launch an ERC-20 coin for your project
-
-
-
-
- );
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token-info-fieldset.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/token-info/token-info-fieldset.tsx
similarity index 58%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token-info-fieldset.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/token-info/token-info-fieldset.tsx
index 5eb5e0da437..063e56d5511 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token-info-fieldset.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token/token-info/token-info-fieldset.tsx
@@ -2,23 +2,15 @@
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
-import { Button } from "@/components/ui/button";
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";
+import { SocialUrlsFieldset } from "../../_common/SocialUrls";
+import { StepCard } from "../../_common/step-card";
+import type { TokenInfoForm } from "../_common/form";
export function TokenInfoFieldset(props: {
client: ThirdwebClient;
@@ -31,7 +23,10 @@ export function TokenInfoFieldset(props: {
-
+
);
}
-
-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]/(sidebar)/hooks/project-contracts.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/hooks/project-contracts.ts
index fceb0086cca..2e8e2dc42b9 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/hooks/project-contracts.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/hooks/project-contracts.ts
@@ -11,7 +11,7 @@ export function useAddContractToProject() {
contractAddress: string;
chainId: string;
deploymentType: "asset" | undefined;
- contractType: "DropERC20" | undefined;
+ contractType: "DropERC20" | "DropERC721" | "DropERC1155" | undefined;
}) => {
const res = await apiServerProxy({
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/contracts`,
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx
index 0c43f08417e..02d801cae98 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx
@@ -505,11 +505,11 @@ function ProjectImageSetting(props: {
diff --git a/apps/dashboard/src/app/bridge/constants.ts b/apps/dashboard/src/app/bridge/constants.ts
index f8ddb246926..a21ca079c9c 100644
--- a/apps/dashboard/src/app/bridge/constants.ts
+++ b/apps/dashboard/src/app/bridge/constants.ts
@@ -18,7 +18,6 @@ import { setThirdwebDomains } from "thirdweb/utils";
function getBridgeThirdwebClient() {
if (getVercelEnv() !== "production") {
- console.log("Setting domains for bridge app", THIRDWEB_BRIDGE_URL);
// if not on production: run this when creating a client to set the domains
setThirdwebDomains({
rpc: THIRDWEB_RPC_DOMAIN,
diff --git a/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx b/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx
index 3e03494cf18..772d05d935f 100644
--- a/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx
+++ b/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx
@@ -305,6 +305,7 @@ export function PaymentLinkForm() {
diff --git a/apps/dashboard/src/components/configure-networks/Form/IconUpload.tsx b/apps/dashboard/src/components/configure-networks/Form/IconUpload.tsx
index 69559a12971..dffbb909e01 100644
--- a/apps/dashboard/src/components/configure-networks/Form/IconUpload.tsx
+++ b/apps/dashboard/src/components/configure-networks/Form/IconUpload.tsx
@@ -43,6 +43,7 @@ export const IconUpload: React.FC<{
return (
= ({ form }) => {
+> = ({ form, client }) => {
return (