From 99d6b3b151c2af6f0df41513eff44931362d6b11 Mon Sep 17 00:00:00 2001 From: MananTank Date: Tue, 10 Jun 2025 21:50:05 +0000 Subject: [PATCH] Dashboard: Add NFT creation wizard in Asset page (#7315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on improving the handling of file inputs and media uploads within the application. It introduces new functionalities for managing blob URLs, refines error handling, and enhances the user interface for uploading assets. ### Detailed summary - Added `fileToBlobUrl` function to convert files to blob URLs. - Enhanced `FileInput` components to accept a `client` prop for better integration. - Updated error handling in several components to provide more detailed feedback. - Refined layouts and styles for asset creation pages. - Introduced new schemas for validating NFT and social URL data. - Improved tracking functionality for NFT creation steps. - Added support for handling multiple file types in uploads. - Enhanced the user interface for attributes and social URLs input fields. > The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.tsx`, `apps/dashboard/src/core-ui/batch-upload/batch-table.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/sales/sales-settings.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/collection-info/nft-collection-info-fieldset.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/snapshot-upload.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token-info-fieldset.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/NFTMediaFormGroup.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page-ui.tsx`, `apps/dashboard/src/components/shared/FileInput.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/shared-metadata-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-airdrop.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/process-files.ts`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/mint-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/single-upload-nft.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/update-metadata-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/launch/launch-token.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-nfts.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch/launch-nft.tsx` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit - **New Features** - Added logic to always display the selected token in token selectors. - Introduced a styled drag-and-drop file upload area with error handling and reset options. - Added reusable components: file preview for files and URLs, step cards with navigation and tracking, social URLs fieldset, downloadable file button, batch upload instructions, and NFT attribute management. - Launched a multi-step NFT creation workflow covering collection info, asset upload (single and batch), sales settings, and launch steps. - Added schema validation for NFT metadata including pricing, social URLs, and blockchain addresses. - Enabled batch uploading and inline editing of NFTs with price, currency, and supply management. - Added advanced sales and royalty settings with real-time validation and analytics tracking. - **Enhancements** - Improved error reporting in toast notifications with detailed parsed messages. - Centralized and streamlined media file handling by removing redundant hooks and consolidating upload logic. - Enhanced navigation and event tracking across multi-step asset creation flows. - Updated UI components to accept client context for consistent file handling. - Refined form validation by reusing common schemas. - **Bug Fixes** - Corrected NFT supply column label to "Circulating Supply". - Improved loading placeholder text in token selectors. - **Refactor** - Consolidated CSV upload logic and replaced custom drag-and-drop UIs with a unified DropZone component. - Replaced multiple media preview components with a single FilePreview component. - Updated prop types and component interfaces for better consistency and maintainability. - Removed deprecated hooks and unused imports to reduce technical debt. - Reorganized and standardized import paths across modules. - Simplified step execution logic in launch flows and enhanced retry capabilities. - **Documentation** - Added Storybook stories for DropZone, multi-step status, and NFT upload components to support visual testing and documentation. - **Chores** - Exported utility functions and constants for broader use. - Improved styling flexibility by enhancing component className handling. --- .changeset/better-owls-flash.md | 6 + .../dashboard/src/@/components/blocks/Img.tsx | 6 +- .../src/@/components/blocks/TokenSelector.tsx | 13 +- .../blocks/drop-zone/drop-zone.stories.tsx | 53 ++ .../components/blocks/drop-zone/drop-zone.tsx | 81 +++ .../multi-step-status.stories.tsx | 23 +- .../multi-step-status/multi-step-status.tsx | 23 +- .../src/@/components/ui/decimal-input.tsx | 2 + apps/dashboard/src/@/lib/file-to-url.ts | 4 + .../(dashboard)/(bridge)/routes/page.tsx | 2 +- .../components/client/FaucetButton.tsx | 8 +- .../(chain)/[chain_id]/(chainPage)/layout.tsx | 8 +- .../claim-conditions/snapshot-upload.tsx | 52 +- .../[chain_id]/[contractAddress]/layout.tsx | 2 +- .../modules/components/BatchMetadata.tsx | 6 +- .../modules/components/Mintable.tsx | 6 +- .../components/nft/NFTMediaFormGroup.tsx | 86 +-- .../components/nft/handleNFTMediaUpload.ts | 85 +++ .../nfts/[tokenId]/components/claim-tab.tsx | 8 +- .../components/update-metadata-form.tsx | 197 +++---- .../nfts/components/lazy-mint-form.tsx | 174 ++---- .../nfts/components/mint-form.tsx | 177 +++--- .../nfts/components/shared-metadata-form.tsx | 151 ++--- .../nfts/components/table.tsx | 2 +- .../settings/components/metadata.tsx | 4 +- .../tokens/components/airdrop-upload.tsx | 48 +- .../settings/AccountSettingsPageUI.tsx | 4 +- .../team-onboarding/TeamInfoForm.stories.tsx | 3 +- .../team-onboarding/TeamInfoForm.tsx | 3 + .../team-onboarding/team-onboarding.tsx | 1 + .../client/auth-options-form.client.tsx | 2 +- .../general/TeamGeneralSettingsPageUI.tsx | 4 +- .../[project_slug]/(sidebar)/assets/cards.tsx | 20 +- .../assets/create/_common/PageHeader.tsx | 56 ++ .../assets/create/_common/SocialUrls.tsx | 106 ++++ .../assets/create/_common/chain-overview.tsx | 24 + .../create/_common/download-file-button.tsx | 42 ++ .../assets/create/_common/file-preview.tsx | 64 +++ .../(sidebar)/assets/create/_common/schema.ts | 35 ++ .../assets/create/_common/step-card.tsx | 98 ++++ .../assets/create/create-token-card.tsx | 81 --- .../assets/create/nft/_common/form.ts | 64 +++ .../assets/create/nft/_common/pages.ts | 6 + .../assets/create/nft/_common/tracking.ts | 91 +++ .../nft-collection-info-fieldset.tsx | 134 +++++ .../assets/create/nft/create-nft-page-ui.tsx | 172 ++++++ .../assets/create/nft/create-nft-page.tsx | 503 ++++++++++++++++ .../assets/create/nft/launch/launch-nft.tsx | 509 ++++++++++++++++ .../(sidebar)/assets/create/nft/page.tsx | 66 +++ .../create/nft/sales/sales-settings.tsx | 111 ++++ .../batch-upload-instructions.tsx | 417 ++++++++++++++ .../batch-upload/batch-upload-nfts.tsx | 544 ++++++++++++++++++ .../upload-nfts/batch-upload/process-files.ts | 327 +++++++++++ .../assets/create/nft/upload-nfts/schema.ts | 68 +++ .../upload-nfts/single-upload/attributes.tsx | 106 ++++ .../single-upload/single-upload-nft.tsx | 339 +++++++++++ .../nft/upload-nfts/upload-nfts.stories.tsx | 119 ++++ .../create/nft/upload-nfts/upload-nfts.tsx | 117 ++++ .../assets/create/{ => token/_common}/form.ts | 37 +- .../create/{ => token/_common}/tracking.ts | 7 +- .../{ => token}/create-token-page-impl.tsx | 20 +- .../{ => token}/create-token-page.client.tsx | 6 +- .../{ => token}/create-token-page.stories.tsx | 0 .../distribution/token-airdrop.tsx | 151 ++--- .../distribution/token-distribution.tsx | 9 +- .../{ => token}/distribution/token-sale.tsx | 2 +- .../{ => token}/launch/launch-token.tsx | 282 +++++---- .../assets/create/{ => token}/page.tsx | 61 +- .../token-info}/token-info-fieldset.tsx | 107 +--- .../(sidebar)/hooks/project-contracts.ts | 2 +- .../settings/ProjectGeneralSettingsPage.tsx | 4 +- apps/dashboard/src/app/bridge/constants.ts | 1 - .../client/PaymentLinkForm.client.tsx | 2 +- .../configure-networks/Form/IconUpload.tsx | 1 + .../contract-metadata-fieldset.tsx | 8 +- .../contract-deploy-form/custom-contract.tsx | 4 +- .../landing-fieldset.tsx | 15 +- .../shared/sources-accordion.tsx | 1 - .../tables/contract-table.tsx | 2 + .../forms/properties.shared.tsx | 4 + .../embedded-wallets/Configure/index.tsx | 4 +- .../components/inputs/BasisPointsInput.tsx | 6 +- .../src/components/ipfs-upload/button.tsx | 5 +- .../src/components/shared/FileInput.tsx | 97 +--- .../solidity-inputs/string-input.tsx | 1 + .../core-ui/batch-upload/batch-lazy-mint.tsx | 8 +- .../src/core-ui/batch-upload/batch-table.tsx | 57 +- apps/dashboard/src/hooks/useCsvUpload.ts | 13 +- apps/dashboard/src/hooks/useImageFileOrUrl.ts | 15 - .../dashboard/src/tw-components/nft-media.tsx | 10 +- apps/dashboard/src/utils/batch.ts | 9 +- apps/dashboard/tsconfig.json | 3 +- .../web/ui/MediaRenderer/MediaRenderer.tsx | 2 +- .../src/react/web/utils/resolveMimeType.ts | 9 +- 94 files changed, 5066 insertions(+), 1362 deletions(-) create mode 100644 .changeset/better-owls-flash.md create mode 100644 apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.stories.tsx create mode 100644 apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx create mode 100644 apps/dashboard/src/@/lib/file-to-url.ts create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/handleNFTMediaUpload.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/PageHeader.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/SocialUrls.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/chain-overview.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/download-file-button.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/file-preview.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/schema.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/step-card.tsx delete mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-card.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/form.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/pages.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/_common/tracking.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/collection-info/nft-collection-info-fieldset.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page-ui.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch/launch-nft.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/sales/sales-settings.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-nfts.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/process-files.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/schema.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/attributes.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/single-upload-nft.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.tsx rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token/_common}/form.ts (71%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token/_common}/tracking.ts (93%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/create-token-page-impl.tsx (95%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/create-token-page.client.tsx (97%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/create-token-page.stories.tsx (100%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/distribution/token-airdrop.tsx (76%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/distribution/token-distribution.tsx (95%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/distribution/token-sale.tsx (98%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/launch/launch-token.tsx (60%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token}/page.tsx (50%) rename apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/{ => token/token-info}/token-info-fieldset.tsx (58%) delete mode 100644 apps/dashboard/src/hooks/useImageFileOrUrl.ts diff --git a/.changeset/better-owls-flash.md b/.changeset/better-owls-flash.md new file mode 100644 index 00000000000..13f692e7ca9 --- /dev/null +++ b/.changeset/better-owls-flash.md @@ -0,0 +1,6 @@ +--- +"thirdweb": patch +--- + +- Add support for blob urls in `MediaRenderer` component +- Fix `className` prop not set in loading state of `MediaRenderer` component \ No newline at end of file diff --git a/apps/dashboard/src/@/components/blocks/Img.tsx b/apps/dashboard/src/@/components/blocks/Img.tsx index 05af128c490..e1fcc37142a 100644 --- a/apps/dashboard/src/@/components/blocks/Img.tsx +++ b/apps/dashboard/src/@/components/blocks/Img.tsx @@ -11,6 +11,7 @@ type imgElementProps = React.DetailedHTMLProps< skeleton?: React.ReactNode; fallback?: React.ReactNode; src: string | undefined; + containerClassName?: string; }; export function Img(props: imgElementProps) { @@ -23,7 +24,8 @@ export function Img(props: imgElementProps) { : props.src === "" ? "fallback" : _status; - const { className, fallback, skeleton, ...restProps } = props; + const { className, fallback, skeleton, containerClassName, ...restProps } = + props; const defaultSkeleton =
; const defaultFallback =
; const imgRef = useRef(null); @@ -47,7 +49,7 @@ export function Img(props: imgElementProps) { }, []); return ( -
+
option.value === selectedValue) + ) { + options.push({ + label: props.selectedToken?.address || "Unknown", + value: selectedValue, + }); + } + return ( ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isError: false, + onDrop: () => {}, + title: "This is a title", + description: "This is a description for drop zone", + accept: undefined, + resetButton: undefined, + }, +}; + +export const ErrorState: Story = { + args: { + isError: true, + onDrop: () => {}, + title: "this is title", + description: "This is a description", + accept: undefined, + resetButton: undefined, + }, +}; + +export const ErrorStateWithResetButton: Story = { + args: { + isError: true, + onDrop: () => {}, + title: "this is title", + description: "This is a description", + accept: undefined, + resetButton: { + label: "Remove Files", + onClick: () => {}, + }, + }, +}; diff --git a/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx new file mode 100644 index 00000000000..4bc7612177e --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx @@ -0,0 +1,81 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { UploadIcon, XIcon } from "lucide-react"; +import { useDropzone } from "react-dropzone"; + +export function DropZone(props: { + isError: boolean; + onDrop: (acceptedFiles: File[]) => void; + title: string; + description: string; + resetButton: + | { + label: string; + onClick: () => void; + } + | undefined; + className?: string; + accept: string | undefined; +}) { + const { getRootProps, getInputProps } = useDropzone({ + onDrop: props.onDrop, + }); + + const { resetButton } = props; + + return ( +
+ +
+ {!props.isError && ( +
+
+ +
+

+ {props.title} +

+

+ {props.description} +

+
+ )} + + {props.isError && ( +
+
+ +
+

+ {props.title} +

+

+ {props.description} +

+ + {resetButton && ( + + )} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx index 3dbe5165115..e7698c97641 100644 --- a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx @@ -16,45 +16,34 @@ const meta = { export default meta; type Story = StoryObj; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - export const AllStates: Story = { args: { + onRetry: () => {}, steps: [ { status: { type: "completed" }, label: "Connect Wallet", - execute: async () => { - await sleep(1000); - }, + id: "connect-wallet", }, { status: { type: "pending" }, label: "Sign Message", - execute: async () => { - await sleep(1000); - }, + id: "sign-message", }, { status: { type: "error", message: "This is an error message" }, label: "Approve Transaction", - execute: async () => { - await sleep(1000); - }, + id: "approve-transaction", }, { status: { type: "idle" }, label: "Confirm Transaction", - execute: async () => { - await sleep(1000); - }, + id: "confirm-transaction", }, { status: { type: "idle" }, label: "Finalize", - execute: async () => { - await sleep(1000); - }, + id: "finalize", }, ], }, diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx index 7779def1757..3f56c48c287 100644 --- a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx @@ -10,21 +10,23 @@ import { import { DynamicHeight } from "../../ui/DynamicHeight"; import { Spinner } from "../../ui/Spinner/Spinner"; -export type MultiStepState = { +export type MultiStepState = { + id: T; status: | { type: "idle" | "pending" | "completed"; } | { type: "error"; - message: string | React.ReactNode; + message: React.ReactNode; }; label: string; - execute: () => Promise; + description?: string; }; -export function MultiStepStatus(props: { - steps: MultiStepState[]; +export function MultiStepStatus(props: { + steps: MultiStepState[]; + onRetry: (step: MultiStepState) => void; }) { return ( @@ -55,6 +57,15 @@ export function MultiStepStatus(props: { {step.label}

+ {/* show description when this step is active */} + {(step.status.type === "pending" || + step.status.type === "error") && + step.description && ( +

+ {step.description} +

+ )} + {step.status.type === "error" && (

@@ -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 = ({ })} >
- Metadata + Metadata
@@ -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 = ({ })} >
- Metadata + Metadata xx
@@ -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 = ({
- +
- {isDragActive ? ( + {dropzone.isDragActive ? ( Drop the files here @@ -178,9 +174,9 @@ export const AirdropUpload: React.FC = ({ - {noCsv + {csvUpload.noCsv ? `No valid CSV file found, make sure your CSV includes the "address" & "quantity" column.` : "Drag & Drop a CSV file here"} diff --git a/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx b/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx index 1b1056880e9..4f2e759f85a 100644 --- a/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx +++ b/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx @@ -137,12 +137,12 @@ function AccountAvatarFormControl(props: {

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({
{ - console.log(errors); + console.error(errors); })} className="flex flex-col gap-8" > diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx index 09c8dc6b90b..198dba545d5 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx @@ -221,11 +221,11 @@ function TeamAvatarFormControl(props: {
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 ( + + + +
+ {/* left */} + + + form.setValue("image", file, { + shouldTouch: true, + }) + } + className="rounded-lg border-border bg-background transition-all duration-200 hover:border-active-border hover:bg-background" + /> + + + {/* right */} +
+ {/* name + symbol */} +
+ + + + + + + +
+ + {/* chain */} + + + { + form.setValue("chain", chain.toString()); + props.onChainUpdated(); + }} + disableChainId + /> + + + + +