diff --git a/apps/dashboard/public/drops/zerion.mp4 b/apps/dashboard/public/drops/zerion.mp4 new file mode 100644 index 00000000000..cced655ec6c Binary files /dev/null and b/apps/dashboard/public/drops/zerion.mp4 differ diff --git a/apps/dashboard/src/@3rdweb-sdk/react/components/connect-wallet/index.tsx b/apps/dashboard/src/@3rdweb-sdk/react/components/connect-wallet/index.tsx index 3e2a2d07234..ca150d3d81a 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/components/connect-wallet/index.tsx +++ b/apps/dashboard/src/@3rdweb-sdk/react/components/connect-wallet/index.tsx @@ -35,6 +35,7 @@ export const CustomConnectWallet = (props: { connectButtonClassName?: string; signInLinkButtonClassName?: string; detailsButtonClassName?: string; + chain?: Chain; }) => { const thirdwebClient = useThirdwebClient(); const loginRequired = @@ -204,6 +205,7 @@ export const CustomConnectWallet = (props: { }, }, }} + chain={props.chain} /> +
+ {/* */} +
{props.children}
+ +
+ + ); +} diff --git a/apps/dashboard/src/app/drops/[slug]/mint-ui.tsx b/apps/dashboard/src/app/drops/[slug]/mint-ui.tsx new file mode 100644 index 00000000000..24093d6ba31 --- /dev/null +++ b/apps/dashboard/src/app/drops/[slug]/mint-ui.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useThirdwebClient } from "@/constants/thirdweb.client"; +import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet"; +import { MinusIcon, PlusIcon } from "lucide-react"; +import { useState } from "react"; +import type React from "react"; +import { toast } from "sonner"; +import type { ThirdwebContract } from "thirdweb"; +import { balanceOf as balanceOfERC721 } from "thirdweb/extensions/erc721"; +import { balanceOf as balanceOfERC1155 } from "thirdweb/extensions/erc1155"; +import { + ClaimButton, + MediaRenderer, + useActiveAccount, + useReadContract, +} from "thirdweb/react"; + +type Props = { + contract: ThirdwebContract; + displayName: string; + description: string; + thumbnail: string; + hideQuantitySelector?: boolean; + hideMintToCustomAddress?: boolean; +} & ({ type: "erc1155"; tokenId: bigint } | { type: "erc721" }) & + ( + | { + pricePerToken: number; + currencySymbol: string | null; + noActiveClaimCondition: false; + quantityLimitPerWallet: bigint; + } + | { noActiveClaimCondition: true } + ); + +export function NftMint(props: Props) { + const [isMinting, setIsMinting] = useState(false); + const [quantity, setQuantity] = useState(1); + const [useCustomAddress, setUseCustomAddress] = useState(false); + const [customAddress, setCustomAddress] = useState(""); + const account = useActiveAccount(); + const client = useThirdwebClient(); + + const decreaseQuantity = () => { + setQuantity((prev) => Math.max(1, prev - 1)); + }; + + const increaseQuantity = () => { + setQuantity((prev) => prev + 1); // Assuming a max of 10 NFTs can be minted at once + }; + + const handleQuantityChange = (e: React.ChangeEvent) => { + const value = Number.parseInt(e.target.value); + if (!Number.isNaN(value)) { + setQuantity(Math.min(Math.max(1, value))); + } + }; + + const balance721Query = useReadContract(balanceOfERC721, { + contract: props.contract, + owner: account?.address || "", + queryOptions: { + enabled: props.type === "erc721" && !!account?.address, + }, + }); + + const balance1155Query = useReadContract(balanceOfERC1155, { + contract: props.contract, + owner: account?.address || "", + tokenId: props.type === "erc1155" ? props.tokenId : 0n, + queryOptions: { + enabled: props.type === "erc1155" && !!account?.address, + }, + }); + + const ownedAmount = + props.type === "erc1155" + ? balance1155Query.data || 0n + : balance721Query.data || 0n; + + const fullyMinted = + props.noActiveClaimCondition === false && + props.quantityLimitPerWallet === ownedAmount; + + return ( +
+ + +
+ + {!props.noActiveClaimCondition && ( + + {props.pricePerToken === 0 + ? "Free" + : `${props.pricePerToken} ${props.currencySymbol}/each`} + + )} +
+

{props.displayName}

+

{props.description}

+ {!props.hideQuantitySelector && !props.noActiveClaimCondition && ( +
+
+ + + +
+
+ Total: {props.pricePerToken * quantity} {props.currencySymbol} +
+
+ )} + + {!props.hideMintToCustomAddress && ( +
+ + +
+ )} + {useCustomAddress && ( +
+ setCustomAddress(e.target.value)} + className="w-full" + /> +
+ )} +
+ + {account ? ( + { + toast.loading("Minting NFT", { id: "toastId" }); + setIsMinting(true); + }} + onTransactionConfirmed={() => { + toast.success("Minted successfully", { id: "toastId" }); + setIsMinting(false); + }} + onError={(err) => { + toast.error(err.message, { id: "toastId" }); + setIsMinting(false); + }} + > + {fullyMinted + ? "Minted" + : props.noActiveClaimCondition + ? "Minting not ready" + : `${quantity > 1 ? `Mint ${quantity} NFTs` : "Mint"}`} + + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/dashboard/src/app/drops/[slug]/opengraph-image.tsx b/apps/dashboard/src/app/drops/[slug]/opengraph-image.tsx new file mode 100644 index 00000000000..5176c666b3a --- /dev/null +++ b/apps/dashboard/src/app/drops/[slug]/opengraph-image.tsx @@ -0,0 +1,161 @@ +/* eslint-disable @next/next/no-img-element */ +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { ImageResponse } from "next/og"; +import { download } from "thirdweb/storage"; +import { fetchChain } from "utils/fetchChain"; +import { DROP_PAGES } from "./data"; + +// Route segment config +export const runtime = "edge"; + +// Image metadata +export const alt = "thirdweb chain og image"; +export const size = { + width: 1200, + height: 630, +}; + +export const contentType = "image/png"; + +const TWLogo: React.FC = () => ( + // biome-ignore lint/a11y/noSvgWithoutTitle: not needed + + + + + + + + + + + + + + + +); + +// Image generation +export default async function Image({ params }: { params: { slug: string } }) { + const slug = params.slug; + const project = DROP_PAGES.find((p) => p.slug === slug); + if (!project) { + return new Response("Page not found", { status: 400 }); + } + const chain = await fetchChain(project.chainId); + if (!chain) { + return new Response("Chain not found", { status: 400 }); + } + + // TODO: handle svg - doesn't work right now + const hasWorkingChainIcon = chain.icon?.url && chain.icon?.format !== "svg"; + + const [interBold, chainIcon, imageData] = await Promise.all([ + // fetch the font we use + fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) => + res.arrayBuffer(), + ), + // download the chain icon if there is one + chain.icon?.url && hasWorkingChainIcon + ? download({ uri: chain.icon.url, client: getThirdwebClient() }).then( + (res) => res.arrayBuffer(), + ) + : undefined, + // download the background image (based on chain) + fetch( + chain.icon?.url && hasWorkingChainIcon + ? new URL( + "og-lib/assets/chain/bg-with-icon.png", + + import.meta.url, + ) + : new URL("og-lib/assets/chain/bg-no-icon.png", import.meta.url), + ).then((res) => res.arrayBuffer()), + ]); + + return new ImageResponse( +
+ + {/* the actual component starts here */} + + {hasWorkingChainIcon && ( + + )} + +
+
+

+ {chain.name} +

+

x

+
+ +
+
+
+
, + // ImageResponse options + { + // For convenience, we can re-use the exported opengraph-image + // size config to also set the ImageResponse's width and height. + ...size, + fonts: [ + { + name: "Inter", + data: interBold, + style: "normal", + weight: 700, + }, + ], + }, + ); +} diff --git a/apps/dashboard/src/app/drops/[slug]/page.tsx b/apps/dashboard/src/app/drops/[slug]/page.tsx new file mode 100644 index 00000000000..41d084d0cdd --- /dev/null +++ b/apps/dashboard/src/app/drops/[slug]/page.tsx @@ -0,0 +1,118 @@ +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { defineDashboardChain } from "lib/defineDashboardChain"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getContract, toTokens } from "thirdweb"; +import { getContractMetadata } from "thirdweb/extensions/common"; +import { getCurrencyMetadata } from "thirdweb/extensions/erc20"; +import { + getActiveClaimCondition as getActiveClaimCondition721, + getNFT as getNFT721, +} from "thirdweb/extensions/erc721"; +import { + getActiveClaimCondition as getActiveClaimCondition1155, + getNFT as getNFT1155, +} from "thirdweb/extensions/erc1155"; +import { DROP_PAGES } from "./data"; +import { NftMint } from "./mint-ui"; + +export async function generateMetadata({ + params, +}: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const project = DROP_PAGES.find((p) => p.slug === slug); + if (!project) { + return notFound(); + } + return project.metadata; +} + +export default async function DropPage({ + params, +}: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + const project = DROP_PAGES.find((p) => p.slug === slug); + + if (!project) { + return notFound(); + } + // eslint-disable-next-line no-restricted-syntax + const chain = defineDashboardChain(project.chainId, undefined); + const client = getThirdwebClient(); + + const contract = getContract({ + address: project.contractAddress, + chain, + client, + }); + + const [nft, claimCondition, contractMetadata] = await Promise.all([ + project.type === "erc1155" + ? getNFT1155({ contract, tokenId: project.tokenId }) + : getNFT721({ contract, tokenId: 0n }), + project.type === "erc1155" + ? getActiveClaimCondition1155({ + contract, + tokenId: project.tokenId, + }).catch(() => undefined) + : getActiveClaimCondition721({ contract }).catch(() => undefined), + getContractMetadata({ contract }), + ]); + + const thumbnail = + project.thumbnail || nft.metadata.image || contractMetadata.image || ""; + + const displayName = contractMetadata.name || nft.metadata.name || ""; + + const description = + typeof contractMetadata.description === "string" && + contractMetadata.description + ? contractMetadata.description + : nft.metadata.description || ""; + + if (!claimCondition) { + return ( + + ); + } + + const currencyMetadata = claimCondition.currency + ? await getCurrencyMetadata({ + contract: getContract({ + address: claimCondition.currency, + chain, + client, + }), + }) + : undefined; + + if (!currencyMetadata) { + return notFound(); + } + + const pricePerToken = Number( + toTokens(claimCondition.pricePerToken, currencyMetadata.decimals), + ); + + return ( + + ); +} diff --git a/apps/dashboard/src/components/product-pages/common/Topnav.tsx b/apps/dashboard/src/components/product-pages/common/Topnav.tsx index e3434f185a4..6c53c8bd982 100644 --- a/apps/dashboard/src/components/product-pages/common/Topnav.tsx +++ b/apps/dashboard/src/components/product-pages/common/Topnav.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Box, Container, Flex, useBreakpointValue } from "@chakra-ui/react"; import { useScrollPosition } from "@n8tb1t/use-scroll-position"; import { Logo } from "components/logo";