Skip to content

Commit 417b970

Browse files
committed
[TOOL-4802] NFT Asset Page
1 parent 35a909a commit 417b970

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)