Skip to content

[TOOL-4802] NFT Asset Page #7332

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-cooks-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Fix `NFTMetadata` type
25 changes: 25 additions & 0 deletions apps/dashboard/src/@/components/Responsive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";
import { Suspense } from "react";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { useIsMobile } from "../hooks/use-mobile";

export function ResponsiveLayout(props: {
desktop: React.ReactNode;
mobile: React.ReactNode;
fallback: React.ReactNode;
debugMode?: boolean;
}) {
const isMobile = useIsMobile();

if (props.debugMode) {
return props.fallback;
}

return (
<Suspense fallback={props.fallback}>
<ClientOnly ssr={props.fallback}>
{isMobile ? props.mobile : props.desktop}
</ClientOnly>
</Suspense>
);
}
37 changes: 37 additions & 0 deletions apps/dashboard/src/@/components/blocks/media-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { useEffect, useState } from "react";
import { MediaRenderer, type MediaRendererProps } from "thirdweb/react";
import { cn } from "../../lib/utils";
import { Skeleton } from "../ui/skeleton";

export function CustomMediaRenderer(props: MediaRendererProps) {
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);

// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (loadedSrc && loadedSrc !== props.src) {
setLoadedSrc(undefined);
}
}, [loadedSrc, props.src]);

return (
<div
className="relative"
onLoad={() => {
if (props.src) {
setLoadedSrc(props.src);
}
}}
>
{!loadedSrc && <Skeleton className="absolute inset-0" />}
<MediaRenderer
{...props}
className={cn(
props.className,
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative z-10",
)}
/>
</div>
);
}
6 changes: 5 additions & 1 deletion apps/dashboard/src/@/components/blocks/wallet-address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function WalletAddress(props: {
className?: string;
iconClassName?: string;
client: ThirdwebClient;
preventOpenOnFocus?: boolean;
}) {
// default back to zero address if no address provided
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
Expand Down Expand Up @@ -64,7 +65,10 @@ export function WalletAddress(props: {

return (
<HoverCard>
<HoverCardTrigger asChild>
<HoverCardTrigger
asChild
tabIndex={props.preventOpenOnFocus ? -1 : undefined}
>
<Button
onClick={(e) => e.stopPropagation()}
variant="link"
Expand Down
31 changes: 29 additions & 2 deletions apps/dashboard/src/@/components/pagination-buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const PaginationButtons = (props: {
activePage: number;
totalPages: number;
onPageClick: (page: number) => void;
className?: string;
}) => {
const { activePage, totalPages, onPageClick: setPage } = props;
const [inputHasError, setInputHasError] = useState(false);
Expand All @@ -40,11 +41,37 @@ export const PaginationButtons = (props: {
}
}

// if only two pages, show "prev" and "next"
if (totalPages === 2) {
return (
<Pagination className={props.className}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={activePage === 1}
onClick={() => {
setPage(activePage - 1);
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
disabled={activePage === totalPages}
onClick={() => {
setPage(activePage + 1);
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}

// just render all the page buttons directly
if (totalPages <= 6) {
const pages = [...Array(totalPages)].map((_, i) => i + 1);
return (
<Pagination>
<Pagination className={props.className}>
<PaginationContent>
{pages.map((page) => (
<PaginationItem key={page}>
Expand All @@ -64,7 +91,7 @@ export const PaginationButtons = (props: {
}

return (
<Pagination>
<Pagination className={props.className}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
Expand Down
6 changes: 5 additions & 1 deletion apps/dashboard/src/@/constants/server-envs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import "server-only";
import { experimental_taintUniqueValue } from "react";
import { experimental_taintUniqueValue as _experimental_taintUniqueValue } from "react";
import { isProd } from "./env-utils";

// on storybook, experimental_taintUniqueValue is not available, because its not available in the react version we are using - its added by next.js
const experimental_taintUniqueValue =
_experimental_taintUniqueValue || (() => {});

// Make sure to taint the server only envs here with experimental_taintUniqueValue ONLY if they contain a UNIQUE sensitive value
// if an env has a generic value that may appear naturally in client components - do not taint it

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { resolveFunctionSelectors } from "lib/selectors";
import type { ThirdwebContract } from "thirdweb";
import { isERC20 } from "thirdweb/extensions/erc20";
import { isGetNFTsSupported as ERC721_isGetNFTsSupported } from "thirdweb/extensions/erc721";
import { isGetNFTsSupported as ERC1155_isGetNFTsSupported } from "thirdweb/extensions/erc1155";
import { supportedERCs } from "./detectedFeatures/supportedERCs";

export type NewPublicPageType = "erc20";
export type NewPublicPageType = "erc20" | "erc1155" | "erc721";

export async function shouldRenderNewPublicPage(
contract: ThirdwebContract,
Expand All @@ -16,9 +18,29 @@ export async function shouldRenderNewPublicPage(
return false;
}

const isERC20Contract = isERC20(functionSelectors);
const _supportedERCs = supportedERCs(functionSelectors);

if (isERC20Contract) {
// ERC1155
if (
_supportedERCs.isERC1155 &&
ERC1155_isGetNFTsSupported(functionSelectors)
) {
return {
type: "erc1155",
};
}

// ERC721
if (
_supportedERCs.isERC721 &&
ERC721_isGetNFTsSupported(functionSelectors)
) {
return {
type: "erc721",
};
}

if (_supportedERCs.isERC20) {
return {
type: "erc20",
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { redirectToContractLandingPage } from "../../../../../../team/[team_slug
import { buildContractPagePath } from "../../_utils/contract-page-path";
import { getContractPageParamsInfo } from "../../_utils/getContractFromParams";
import { getContractPageMetadata } from "../../_utils/getContractPageMetadata";
import { shouldRenderNewPublicPage } from "../../_utils/newPublicPage";
import { NFTPublicPage } from "../../public-pages/nft/nft-page";
import { TokenIdPageClient } from "./TokenIdPage.client";
import { TokenIdPage } from "./token-id";

Expand Down Expand Up @@ -40,6 +42,22 @@ export async function SharedNFTTokenPage(props: {
);
}

// public /nfts/[tokenId] page
if (!props.projectMeta) {
const meta = await shouldRenderNewPublicPage(info.serverContract);
if (meta && (meta.type === "erc721" || meta.type === "erc1155")) {
return (
<NFTPublicPage
serverContract={info.serverContract}
clientContract={info.clientContract}
chainMetadata={info.chainMetadata}
tokenId={props.tokenId}
type={meta.type}
/>
);
}
}

const { clientContract, serverContract, isLocalhostChain } = info;
if (isLocalhostChain) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]
import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
import { shouldRenderNewPublicPage } from "../_utils/newPublicPage";
import { ContractNFTPage } from "./ContractNFTPage";
import { ContractNFTPageClient } from "./ContractNFTPage.client";

Expand Down Expand Up @@ -44,6 +45,18 @@ export async function SharedNFTPage(props: {
});
}

// public nft page doesn't have /nfts page
if (!props.projectMeta) {
const shouldHide = await shouldRenderNewPublicPage(info.serverContract);
if (shouldHide) {
redirectToContractLandingPage({
contractAddress: props.contractAddress,
chainIdOrSlug: props.chainIdOrSlug,
projectMeta: props.projectMeta,
});
}
}

return (
<ContractNFTPage
contract={clientContract}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { ToggleThemeButton } from "@/components/color-mode-toggle";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo";
import { PublicPageConnectButton } from "./PublicPageConnectButton";

export function PageHeader() {
export function PageHeader(props: {
containerClassName?: string;
}) {
return (
<div className="border-b border-dashed">
<header className="container flex max-w-8xl justify-between py-3">
<header
className={cn(
"container flex max-w-8xl justify-between py-3",
props.containerClassName,
)}
>
<div className="flex items-center gap-4">
<Link href="/team" className="flex items-center gap-2">
<ThirdwebMiniLogo className="h-6" />
<ThirdwebMiniLogo className="h-5" />
<span className="hidden font-bold text-2xl tracking-tight lg:block">
thirdweb
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { maxUint256 } from "thirdweb/utils";
import { BadgeContainer } from "../../../../../../../../stories/utils";
import { SupplyClaimedProgress } from "./supply-claimed-progress";

const meta = {
title: "Assets/NFT/SupplyClaimedProgress",
component: StoryVariants,
} satisfies Meta<typeof StoryVariants>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Variants: Story = {
args: {},
};

function StoryVariants() {
return (
<div className="container max-w-md space-y-10 py-10">
<BadgeContainer label="10 / Unlimited Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={maxUint256} />
</BadgeContainer>

<BadgeContainer label="500/1000 Supply">
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
</BadgeContainer>

<BadgeContainer label="10/1M Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
</BadgeContainer>

<BadgeContainer label="0/1M Supply">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
</BadgeContainer>

<BadgeContainer label="10/10 Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
</BadgeContainer>

<BadgeContainer label="0 Supply - don't show anything">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
</BadgeContainer>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Progress } from "@/components/ui/progress";
import { InfinityIcon } from "lucide-react";
import { maxUint256 } from "thirdweb/utils";
import { supplyFormatter } from "../nft/format";

export function SupplyClaimedProgress(props: {
claimedSupply: bigint;
totalSupply: bigint;
}) {
// if total supply is unlimited
if (props.totalSupply === maxUint256) {
return (
<p className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">Claimed Supply </span>
<span className="flex items-center gap-1 font-bold text-sm">
{supplyFormatter.format(props.claimedSupply)} /{" "}
<InfinityIcon className="size-4" aria-label="Unlimited" />
</span>
</p>
);
}

// if total supply is 0 - don't show anything
if (props.totalSupply === 0n) {
return null;
}

// multiply by 10k to have precision up to 2 decimal places in percentage value
const soldFractionTimes10KBigInt =
(props.claimedSupply * 10000n) / props.totalSupply;

const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;

return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Supply Claimed</span>
<span className="font-bold text-sm">
{supplyFormatter.format(props.claimedSupply)} /{" "}
{supplyFormatter.format(props.totalSupply)}
</span>
</div>
<Progress value={soldPercentage} className="h-2.5" />
<p className="font-medium text-muted-foreground text-xs">
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
{soldPercentage.toFixed(2)}% Sold
</p>
</div>
);
}
Loading
Loading