Skip to content

Commit 913ea98

Browse files
committed
[TOOL-4802] NFT Asset Page (#7332)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on enhancing the handling of NFTs and contract components within the application, including updates to types, UI components, and functionality related to NFT collections and claims. ### Detailed summary - Updated `NFTMetadata` type to include `slug`. - Added `slug` property in various components. - Introduced `supplyFormatter` for better NFT supply formatting. - Changed contract type handling to `NFTCollection`. - Enhanced `MinimalProject` type to include `slug`. - Updated UI components for better user interaction. - Improved pagination and NFT display logic. - Added new utilities for NFT claim conditions and total counts. - Refactored claim token UI to utilize new components and structures. - Implemented responsive layouts for better mobile support. > The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/nft/overview/nfts-grid.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/nft/overview/buy-nft-drop/buy-nft-drop-ui.client.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/nft/token-viewer/token-viewer.tsx` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced public NFT pages for ERC-721 and ERC-1155 contracts with responsive layouts, NFT grids, token viewer dialogs, and buy functionality. - Added components for token price display, supply claimed progress, and enhanced pagination controls. - Enabled NFT purchases from drops with dedicated UI and transaction handling for ERC-721 and ERC-1155 standards. - Added tabbed navigation for public NFT pages and improved media rendering with loading placeholders. - Introduced a responsive layout component for device-specific rendering and customizable wallet address focus behavior. - Added new components for buying NFTs and editions with form validation, pricing display, and transaction feedback. - Added a token price component with loading skeleton and strikethrough styling. - Added a supply claimed progress bar with detailed percentage display. - Introduced a token viewer dialog with dynamic data fetching and rich metadata display. - Added a paginated NFT grid with detailed claim condition info for ERC-1155 tokens. - Added buy NFT drop card components with server and client rendering support. - Added a custom media renderer component with loading state management. - **Enhancements** - Improved contract and page header components with customizable styling options. - Expanded project and team data structures to include project slugs for better navigation. - Updated wallet address and pagination components with additional props for accessibility and styling. - Refined tracking of click and keyboard events for asset cards. - Simplified purchase UI code by delegating price and supply display to shared components. - Enhanced pagination to handle two-page scenarios with simplified controls. - Updated public page rendering logic to support multiple ERC standards with unified detection. - Improved purchase flows with analytics tracking and toast notifications. - Added support for special user pricing and dynamic claim condition fetching. - **Bug Fixes** - Fixed conditional rendering and redirection logic for public NFT pages based on contract type and metadata presence. - **Documentation** - Added Storybook stories for new UI components including supply claimed progress and NFT drop purchase flows. - **Refactor** - Delegated price and supply display logic to shared components for consistency. - Updated types and props to support new features and improve maintainability. - **Chores** - Broadened NFT metadata field types for improved compatibility with diverse data formats. - Added fallback for React experimental API to prevent errors in unsupported environments. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5ce8a56 commit 913ea98

40 files changed

+2995
-154
lines changed

.changeset/red-cooks-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Fix `NFTMetadata` type
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"use client";
2+
import { Suspense } from "react";
3+
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
4+
import { useIsMobile } from "../hooks/use-mobile";
5+
6+
export function ResponsiveLayout(props: {
7+
desktop: React.ReactNode;
8+
mobile: React.ReactNode;
9+
fallback: React.ReactNode;
10+
debugMode?: boolean;
11+
}) {
12+
const isMobile = useIsMobile();
13+
14+
if (props.debugMode) {
15+
return props.fallback;
16+
}
17+
18+
return (
19+
<Suspense fallback={props.fallback}>
20+
<ClientOnly ssr={props.fallback}>
21+
{isMobile ? props.mobile : props.desktop}
22+
</ClientOnly>
23+
</Suspense>
24+
);
25+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { MediaRenderer, type MediaRendererProps } from "thirdweb/react";
5+
import { cn } from "../../lib/utils";
6+
import { Skeleton } from "../ui/skeleton";
7+
8+
export function CustomMediaRenderer(props: MediaRendererProps) {
9+
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
10+
11+
// eslint-disable-next-line no-restricted-syntax
12+
useEffect(() => {
13+
if (loadedSrc && loadedSrc !== props.src) {
14+
setLoadedSrc(undefined);
15+
}
16+
}, [loadedSrc, props.src]);
17+
18+
return (
19+
<div
20+
className="relative"
21+
onLoad={() => {
22+
if (props.src) {
23+
setLoadedSrc(props.src);
24+
}
25+
}}
26+
>
27+
{!loadedSrc && <Skeleton className="absolute inset-0" />}
28+
<MediaRenderer
29+
{...props}
30+
className={cn(
31+
props.className,
32+
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative z-10",
33+
)}
34+
/>
35+
</div>
36+
);
37+
}

apps/dashboard/src/@/components/blocks/wallet-address.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function WalletAddress(props: {
2424
className?: string;
2525
iconClassName?: string;
2626
client: ThirdwebClient;
27+
preventOpenOnFocus?: boolean;
2728
}) {
2829
// default back to zero address if no address provided
2930
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
@@ -64,7 +65,10 @@ export function WalletAddress(props: {
6465

6566
return (
6667
<HoverCard>
67-
<HoverCardTrigger asChild>
68+
<HoverCardTrigger
69+
asChild
70+
tabIndex={props.preventOpenOnFocus ? -1 : undefined}
71+
>
6872
<Button
6973
onClick={(e) => e.stopPropagation()}
7074
variant="link"

apps/dashboard/src/@/components/pagination-buttons.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const PaginationButtons = (props: {
1919
activePage: number;
2020
totalPages: number;
2121
onPageClick: (page: number) => void;
22+
className?: string;
2223
}) => {
2324
const { activePage, totalPages, onPageClick: setPage } = props;
2425
const [inputHasError, setInputHasError] = useState(false);
@@ -40,11 +41,37 @@ export const PaginationButtons = (props: {
4041
}
4142
}
4243

44+
// if only two pages, show "prev" and "next"
45+
if (totalPages === 2) {
46+
return (
47+
<Pagination className={props.className}>
48+
<PaginationContent>
49+
<PaginationItem>
50+
<PaginationPrevious
51+
disabled={activePage === 1}
52+
onClick={() => {
53+
setPage(activePage - 1);
54+
}}
55+
/>
56+
</PaginationItem>
57+
<PaginationItem>
58+
<PaginationNext
59+
disabled={activePage === totalPages}
60+
onClick={() => {
61+
setPage(activePage + 1);
62+
}}
63+
/>
64+
</PaginationItem>
65+
</PaginationContent>
66+
</Pagination>
67+
);
68+
}
69+
4370
// just render all the page buttons directly
4471
if (totalPages <= 6) {
4572
const pages = [...Array(totalPages)].map((_, i) => i + 1);
4673
return (
47-
<Pagination>
74+
<Pagination className={props.className}>
4875
<PaginationContent>
4976
{pages.map((page) => (
5077
<PaginationItem key={page}>
@@ -64,7 +91,7 @@ export const PaginationButtons = (props: {
6491
}
6592

6693
return (
67-
<Pagination>
94+
<Pagination className={props.className}>
6895
<PaginationContent>
6996
<PaginationItem>
7097
<PaginationPrevious

apps/dashboard/src/@/constants/server-envs.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import "server-only";
2-
import { experimental_taintUniqueValue } from "react";
2+
import { experimental_taintUniqueValue as _experimental_taintUniqueValue } from "react";
33
import { isProd } from "./env-utils";
44

5+
// on storybook, experimental_taintUniqueValue is not available, because its not available in the react version we are using - its added by next.js
6+
const experimental_taintUniqueValue =
7+
_experimental_taintUniqueValue || (() => {});
8+
59
// Make sure to taint the server only envs here with experimental_taintUniqueValue ONLY if they contain a UNIQUE sensitive value
610
// if an env has a generic value that may appear naturally in client components - do not taint it
711

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/newPublicPage.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { resolveFunctionSelectors } from "lib/selectors";
22
import type { ThirdwebContract } from "thirdweb";
3-
import { isERC20 } from "thirdweb/extensions/erc20";
3+
import { isGetNFTsSupported as ERC721_isGetNFTsSupported } from "thirdweb/extensions/erc721";
4+
import { isGetNFTsSupported as ERC1155_isGetNFTsSupported } from "thirdweb/extensions/erc1155";
5+
import { supportedERCs } from "./detectedFeatures/supportedERCs";
46

5-
export type NewPublicPageType = "erc20";
7+
export type NewPublicPageType = "erc20" | "erc1155" | "erc721";
68

79
export async function shouldRenderNewPublicPage(
810
contract: ThirdwebContract,
@@ -16,9 +18,29 @@ export async function shouldRenderNewPublicPage(
1618
return false;
1719
}
1820

19-
const isERC20Contract = isERC20(functionSelectors);
21+
const _supportedERCs = supportedERCs(functionSelectors);
2022

21-
if (isERC20Contract) {
23+
// ERC1155
24+
if (
25+
_supportedERCs.isERC1155 &&
26+
ERC1155_isGetNFTsSupported(functionSelectors)
27+
) {
28+
return {
29+
type: "erc1155",
30+
};
31+
}
32+
33+
// ERC721
34+
if (
35+
_supportedERCs.isERC721 &&
36+
ERC721_isGetNFTsSupported(functionSelectors)
37+
) {
38+
return {
39+
type: "erc721",
40+
};
41+
}
42+
43+
if (_supportedERCs.isERC20) {
2244
return {
2345
type: "erc20",
2446
};

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/shared-nfts-token-page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { redirectToContractLandingPage } from "../../../../../../team/[team_slug
55
import { buildContractPagePath } from "../../_utils/contract-page-path";
66
import { getContractPageParamsInfo } from "../../_utils/getContractFromParams";
77
import { getContractPageMetadata } from "../../_utils/getContractPageMetadata";
8+
import { shouldRenderNewPublicPage } from "../../_utils/newPublicPage";
9+
import { NFTPublicPage } from "../../public-pages/nft/nft-page";
810
import { TokenIdPageClient } from "./TokenIdPage.client";
911
import { TokenIdPage } from "./token-id";
1012

@@ -40,6 +42,22 @@ export async function SharedNFTTokenPage(props: {
4042
);
4143
}
4244

45+
// public /nfts/[tokenId] page
46+
if (!props.projectMeta) {
47+
const meta = await shouldRenderNewPublicPage(info.serverContract);
48+
if (meta && (meta.type === "erc721" || meta.type === "erc1155")) {
49+
return (
50+
<NFTPublicPage
51+
serverContract={info.serverContract}
52+
clientContract={info.clientContract}
53+
chainMetadata={info.chainMetadata}
54+
tokenId={props.tokenId}
55+
type={meta.type}
56+
/>
57+
);
58+
}
59+
}
60+
4361
const { clientContract, serverContract, isLocalhostChain } = info;
4462
if (isLocalhostChain) {
4563
return (

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/shared-nfts-page.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]
33
import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
44
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
55
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
6+
import { shouldRenderNewPublicPage } from "../_utils/newPublicPage";
67
import { ContractNFTPage } from "./ContractNFTPage";
78
import { ContractNFTPageClient } from "./ContractNFTPage.client";
89

@@ -44,6 +45,18 @@ export async function SharedNFTPage(props: {
4445
});
4546
}
4647

48+
// public nft page doesn't have /nfts page
49+
if (!props.projectMeta) {
50+
const shouldHide = await shouldRenderNewPublicPage(info.serverContract);
51+
if (shouldHide) {
52+
redirectToContractLandingPage({
53+
contractAddress: props.contractAddress,
54+
chainIdOrSlug: props.chainIdOrSlug,
55+
projectMeta: props.projectMeta,
56+
});
57+
}
58+
}
59+
4760
return (
4861
<ContractNFTPage
4962
contract={clientContract}

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PageHeader.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import { ToggleThemeButton } from "@/components/color-mode-toggle";
2+
import { cn } from "@/lib/utils";
23
import Link from "next/link";
34
import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo";
45
import { PublicPageConnectButton } from "./PublicPageConnectButton";
56

6-
export function PageHeader() {
7+
export function PageHeader(props: {
8+
containerClassName?: string;
9+
}) {
710
return (
811
<div className="border-b border-dashed">
9-
<header className="container flex max-w-8xl justify-between py-3">
12+
<header
13+
className={cn(
14+
"container flex max-w-8xl justify-between py-3",
15+
props.containerClassName,
16+
)}
17+
>
1018
<div className="flex items-center gap-4">
1119
<Link href="/team" className="flex items-center gap-2">
12-
<ThirdwebMiniLogo className="h-6" />
20+
<ThirdwebMiniLogo className="h-5" />
1321
<span className="hidden font-bold text-2xl tracking-tight lg:block">
1422
thirdweb
1523
</span>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Meta, StoryObj } from "@storybook/nextjs";
2+
import { maxUint256 } from "thirdweb/utils";
3+
import { BadgeContainer } from "../../../../../../../../stories/utils";
4+
import { SupplyClaimedProgress } from "./supply-claimed-progress";
5+
6+
const meta = {
7+
title: "Assets/NFT/SupplyClaimedProgress",
8+
component: StoryVariants,
9+
} satisfies Meta<typeof StoryVariants>;
10+
11+
export default meta;
12+
type Story = StoryObj<typeof meta>;
13+
14+
export const Variants: Story = {
15+
args: {},
16+
};
17+
18+
function StoryVariants() {
19+
return (
20+
<div className="container max-w-md space-y-10 py-10">
21+
<BadgeContainer label="10 / Unlimited Supply">
22+
<SupplyClaimedProgress claimedSupply={10n} totalSupply={maxUint256} />
23+
</BadgeContainer>
24+
25+
<BadgeContainer label="500/1000 Supply">
26+
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
27+
</BadgeContainer>
28+
29+
<BadgeContainer label="10/1M Supply">
30+
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
31+
</BadgeContainer>
32+
33+
<BadgeContainer label="0/1M Supply">
34+
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
35+
</BadgeContainer>
36+
37+
<BadgeContainer label="10/10 Supply">
38+
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
39+
</BadgeContainer>
40+
41+
<BadgeContainer label="0 Supply - don't show anything">
42+
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
43+
</BadgeContainer>
44+
</div>
45+
);
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Progress } from "@/components/ui/progress";
2+
import { InfinityIcon } from "lucide-react";
3+
import { maxUint256 } from "thirdweb/utils";
4+
import { supplyFormatter } from "../nft/format";
5+
6+
export function SupplyClaimedProgress(props: {
7+
claimedSupply: bigint;
8+
totalSupply: bigint;
9+
}) {
10+
// if total supply is unlimited
11+
if (props.totalSupply === maxUint256) {
12+
return (
13+
<p className="flex items-center justify-between gap-2">
14+
<span className="font-medium text-sm">Claimed Supply </span>
15+
<span className="flex items-center gap-1 font-bold text-sm">
16+
{supplyFormatter.format(props.claimedSupply)} /{" "}
17+
<InfinityIcon className="size-4" aria-label="Unlimited" />
18+
</span>
19+
</p>
20+
);
21+
}
22+
23+
// if total supply is 0 - don't show anything
24+
if (props.totalSupply === 0n) {
25+
return null;
26+
}
27+
28+
// multiply by 10k to have precision up to 2 decimal places in percentage value
29+
const soldFractionTimes10KBigInt =
30+
(props.claimedSupply * 10000n) / props.totalSupply;
31+
32+
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
33+
34+
return (
35+
<div className="space-y-2">
36+
<div className="flex items-center justify-between">
37+
<span className="font-medium text-sm">Supply Claimed</span>
38+
<span className="font-bold text-sm">
39+
{supplyFormatter.format(props.claimedSupply)} /{" "}
40+
{supplyFormatter.format(props.totalSupply)}
41+
</span>
42+
</div>
43+
<Progress value={soldPercentage} className="h-2.5" />
44+
<p className="font-medium text-muted-foreground text-xs">
45+
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
46+
{soldPercentage.toFixed(2)}% Sold
47+
</p>
48+
</div>
49+
);
50+
}

0 commit comments

Comments
 (0)