diff --git a/apps/dashboard/src/@/actions/getWalletNFTs.ts b/apps/dashboard/src/@/actions/getWalletNFTs.ts index acf1286db0e..291e4a820bf 100644 --- a/apps/dashboard/src/@/actions/getWalletNFTs.ts +++ b/apps/dashboard/src/@/actions/getWalletNFTs.ts @@ -12,7 +12,7 @@ import type { WalletNFT } from "lib/wallet/nfts/types"; import { getVercelEnv } from "../../lib/vercel-utils"; import { isAlchemySupported } from "../../lib/wallet/nfts/isAlchemySupported"; import { isMoralisSupported } from "../../lib/wallet/nfts/isMoralisSupported"; -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "../constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "../constants/public-envs"; import { MORALIS_API_KEY } from "../constants/server-envs"; type WalletNFTApiReturn = @@ -149,7 +149,7 @@ async function getWalletNFTsFromInsight(params: { const response = await fetch(url, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }); diff --git a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx index 04f86fbb030..a27dd16bd63 100644 --- a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx +++ b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx @@ -41,6 +41,7 @@ type UpsellBannerCardProps = { cta: { text: React.ReactNode; icon?: React.ReactNode; + target?: "_blank"; link: string; }; trackingCategory: string; @@ -55,7 +56,7 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) { return ( @@ -108,6 +107,7 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) { href={props.cta.link} category={props.trackingCategory} label={props.trackingLabel} + target={props.cta.target} > {props.cta.text} {props.cta.icon && {props.cta.icon}} diff --git a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx index a944c819886..fab6079ee65 100644 --- a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx @@ -46,8 +46,12 @@ type ThirdwebAreaChartProps = { // chart className chartClassName?: string; isPending: boolean; + className?: string; + cardContentClassName?: string; hideLabel?: boolean; toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode; + toolTipValueFormatter?: (value: unknown) => React.ReactNode; + emptyChartState?: React.ReactElement; }; export function ThirdwebAreaChart( @@ -56,7 +60,7 @@ export function ThirdwebAreaChart( const configKeys = useMemo(() => Object.keys(props.config), [props.config]); return ( - + {props.header && ( @@ -70,12 +74,16 @@ export function ThirdwebAreaChart( {props.customHeader && props.customHeader} - + {props.isPending ? ( ) : props.data.length === 0 ? ( - + + {props.emptyChartState} + ) : ( @@ -100,6 +108,7 @@ export function ThirdwebAreaChart( props.hideLabel !== undefined ? props.hideLabel : true } labelFormatter={props.toolTipLabelFormatter} + valueFormatter={props.toolTipValueFormatter} /> } /> diff --git a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx index f89ef6ec9d3..799ed703dc5 100644 --- a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx @@ -75,7 +75,9 @@ export function ThirdwebBarChart( {props.isPending ? ( ) : props.data.length === 0 ? ( - {props.emptyChartState} + + {props.emptyChartState} + ) : ( diff --git a/apps/dashboard/src/@/components/ui/LoadingDots.tsx b/apps/dashboard/src/@/components/ui/LoadingDots.tsx new file mode 100644 index 00000000000..864f4c60027 --- /dev/null +++ b/apps/dashboard/src/@/components/ui/LoadingDots.tsx @@ -0,0 +1,10 @@ +export function LoadingDots() { + return ( +
+ Loading... +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/@/components/ui/decimal-input.tsx b/apps/dashboard/src/@/components/ui/decimal-input.tsx new file mode 100644 index 00000000000..2c9f251c3c5 --- /dev/null +++ b/apps/dashboard/src/@/components/ui/decimal-input.tsx @@ -0,0 +1,35 @@ +import { Input } from "./input"; +export function DecimalInput(props: { + value: string; + onChange: (value: string) => void; + maxValue?: number; + id?: string; + className?: string; +}) { + return ( + { + const number = Number(e.target.value); + // ignore if string becomes invalid number + if (Number.isNaN(number)) { + return; + } + if (props.maxValue && number > props.maxValue) { + return; + } + // replace leading multiple zeros with single zero + let cleanedValue = e.target.value.replace(/^0+/, "0"); + // replace leading zero before decimal point + if (!cleanedValue.includes(".")) { + cleanedValue = cleanedValue.replace(/^0+/, ""); + } + props.onChange(cleanedValue || "0"); + }} + /> + ); +} diff --git a/apps/dashboard/src/@/constants/public-envs.ts b/apps/dashboard/src/@/constants/public-envs.ts index 789b5737f46..58bbe701ddf 100644 --- a/apps/dashboard/src/@/constants/public-envs.ts +++ b/apps/dashboard/src/@/constants/public-envs.ts @@ -1,4 +1,4 @@ -export const NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID = +export const NEXT_PUBLIC_DASHBOARD_CLIENT_ID = process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID || ""; export const NEXT_PUBLIC_NEBULA_APP_CLIENT_ID = diff --git a/apps/dashboard/src/@/constants/thirdweb.server.ts b/apps/dashboard/src/@/constants/thirdweb.server.ts index e4b6ce0f2f1..07b68599c6d 100644 --- a/apps/dashboard/src/@/constants/thirdweb.server.ts +++ b/apps/dashboard/src/@/constants/thirdweb.server.ts @@ -1,5 +1,5 @@ import { - NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + NEXT_PUBLIC_DASHBOARD_CLIENT_ID, NEXT_PUBLIC_IPFS_GATEWAY_URL, } from "@/constants/public-envs"; import { @@ -76,7 +76,7 @@ export function getConfiguredThirdwebClient(options: { return createThirdwebClient({ teamId: options.teamId, secretKey: options.secretKey, - clientId: NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID, config: { storage: { gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL, diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/layout.tsx new file mode 100644 index 00000000000..df8af93e28d --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/layout.tsx @@ -0,0 +1,16 @@ +import { TeamHeader } from "../../team/components/TeamHeader/team-header"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/live-stats.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/live-stats.tsx index 225559e7038..3506a49963b 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/live-stats.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/live-stats.tsx @@ -4,7 +4,7 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton"; import { Skeleton } from "@/components/ui/skeleton"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { isProd } from "@/constants/env-utils"; -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; import { useQuery } from "@tanstack/react-query"; import { CircleCheckIcon, XIcon } from "lucide-react"; import { hostnameEndsWith } from "utils/url"; @@ -14,7 +14,7 @@ function useChainStatswithRPC(_rpcUrl: string) { let rpcUrl = _rpcUrl.replace( // eslint-disable-next-line no-template-curly-in-string "${THIRDWEB_API_KEY}", - NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + NEXT_PUBLIC_DASHBOARD_CLIENT_ID, ); // based on the environment hit dev or production 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 ac0bfb13101..1889279a110 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 @@ -24,6 +24,7 @@ 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"; import { AddChainToWallet } from "./components/client/add-chain-to-wallet"; @@ -95,7 +96,10 @@ The following is the user's message: } return ( - <> +
+
+ +
- +
); } diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/metadata-header.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/metadata-header.tsx index 3ea03d70cb2..a2d3b812ed3 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/metadata-header.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/metadata-header.tsx @@ -82,21 +82,19 @@ export const MetadataHeader: React.FC = ({ )} - {chain && ( - - - {cleanedChainName && ( - {cleanedChainName} - )} - - )} + + + {cleanedChainName && ( + {cleanedChainName} + )} +
)} @@ -115,7 +113,7 @@ export const MetadataHeader: React.FC = ({ diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx index 65ce88755df..4da649b8f1d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx @@ -81,7 +81,17 @@ export const PrimaryDashboardButton: React.FC = ({ // if user is on a project page if (projectMeta) { - return null; + return ( + + ); } return ( diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/newPublicPage.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/newPublicPage.ts new file mode 100644 index 00000000000..a463ba6c491 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/newPublicPage.ts @@ -0,0 +1,31 @@ +import { resolveFunctionSelectors } from "lib/selectors"; +import type { ThirdwebContract } from "thirdweb"; +import { isERC20 } from "thirdweb/extensions/erc20"; + +export type NewPublicPageType = "erc20"; + +export async function shouldRenderNewPublicPage( + contract: ThirdwebContract, +): Promise { + try { + const functionSelectors = await resolveFunctionSelectors(contract).catch( + () => undefined, + ); + + if (!functionSelectors) { + return false; + } + + const isERC20Contract = isERC20(functionSelectors); + + if (isERC20Contract) { + return { + type: "erc20", + }; + } + + return false; + } catch { + return false; + } +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/shared-analytics-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/shared-analytics-page.tsx index 9ff09746499..436237b44e6 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/shared-analytics-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/shared-analytics-page.tsx @@ -5,6 +5,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 { ContractAnalyticsPage } from "./ContractAnalyticsPage"; export async function SharedAnalyticsPage(props: { @@ -22,6 +23,18 @@ export async function SharedAnalyticsPage(props: { notFound(); } + // new public page can't show /analytics page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + const [ { eventSelectorToName, writeFnSelectorToName }, { isInsightSupported }, diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/claim-conditions/shared-claim-conditions-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/claim-conditions/shared-claim-conditions-page.tsx index 698b1a20086..c02c148fa31 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/claim-conditions/shared-claim-conditions-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/claim-conditions/shared-claim-conditions-page.tsx @@ -4,6 +4,7 @@ import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[ import { ClaimConditions } from "../_components/claim-conditions/claim-conditions"; import { getContractPageParamsInfo } from "../_utils/getContractFromParams"; import { getContractPageMetadata } from "../_utils/getContractPageMetadata"; +import { shouldRenderNewPublicPage } from "../_utils/newPublicPage"; import { ClaimConditionsClient } from "./ClaimConditions.client"; export async function SharedClaimConditionsPage(props: { @@ -22,6 +23,18 @@ export async function SharedClaimConditionsPage(props: { notFound(); } + // new public page can't show /claim-conditions page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + const { clientContract, serverContract, isLocalhostChain } = info; if (isLocalhostChain) { diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/code/shared-code-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/code/shared-code-page.tsx index 1f83d45e0ec..ac7281d408f 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/code/shared-code-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/code/shared-code-page.tsx @@ -1,7 +1,9 @@ import { notFound } from "next/navigation"; import { resolveContractAbi } from "thirdweb/contract"; import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils"; import { getContractPageParamsInfo } from "../_utils/getContractFromParams"; +import { shouldRenderNewPublicPage } from "../_utils/newPublicPage"; import { ContractCodePage } from "./contract-code-page"; import { ContractCodePageClient } from "./contract-code-page.client"; @@ -20,6 +22,18 @@ export async function SharedCodePage(props: { notFound(); } + // new public page can't show /code page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + const { clientContract, serverContract, chainMetadata, isLocalhostChain } = info; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/cross-chain/shared-cross-chain-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/cross-chain/shared-cross-chain-page.tsx index 95b706faa3e..181da3ef249 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/cross-chain/shared-cross-chain-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/cross-chain/shared-cross-chain-page.tsx @@ -20,8 +20,10 @@ import { eth_getCode, getRpcClient } from "thirdweb/rpc"; import type { TransactionReceipt } from "thirdweb/transaction"; import { type AbiFunction, decodeFunctionData } from "thirdweb/utils"; import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +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 { DataTable } from "./data-table"; import { NoCrossChainPrompt } from "./no-crosschain-prompt"; @@ -48,6 +50,18 @@ export async function SharedCrossChainPage(props: { notFound(); } + // new public page can't show /cross-chain page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + const { clientContract, serverContract } = info; const isModularCore = (await getContractPageMetadata(serverContract)) diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/events/shared-events-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/events/shared-events-page.tsx index 28df9f5db48..cf089d87dfb 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/events/shared-events-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/events/shared-events-page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils"; import { getContractPageParamsInfo } from "../_utils/getContractFromParams"; +import { shouldRenderNewPublicPage } from "../_utils/newPublicPage"; import { EventsFeed } from "./events-feed"; export async function SharedEventsPage(props: { @@ -18,6 +20,18 @@ export async function SharedEventsPage(props: { notFound(); } + // new public page can't show /events page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + return ( = ({ return (
+ {isErc20 && ( + , + target: "_blank", + link: `/${chainSlug}/${contract.address}`, + }} + trackingCategory="erc20-contract" + trackingLabel="view-asset-page" + accentColor="blue" + /> + )} + { - if (isPending) { - return undefined; - } - - const time = (wallets.data || transactions.data || events.data || []).map( - (wallet) => wallet.time, - ); - - return time.map((time) => { - const wallet = wallets.data?.find( - (wallet) => getDayKey(wallet.time) === getDayKey(time), - ); - const transaction = transactions.data?.find( - (transaction) => getDayKey(transaction.time) === getDayKey(time), - ); - const event = events.data?.find((event) => { - return getDayKey(event.time) === getDayKey(time); - }); - - return { - time, - wallets: wallet?.count || 0, - transactions: transaction?.count || 0, - events: event?.count || 0, - }; - }); - }, [wallets.data, transactions.data, events.data, isPending]); - const analyticsPath = buildContractPagePath({ projectMeta: props.projectMeta, chainIdOrSlug: props.chainSlug, @@ -111,10 +71,11 @@ export function ContractAnalyticsOverviewCard(props: { color: "hsl(var(--chart-3))", }, }} - data={mergedData || []} + data={data || []} isPending={isPending} showLegend chartClassName="aspect-[1.5] lg:aspect-[3]" + toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)} customHeader={

Analytics

@@ -141,3 +102,109 @@ export function ContractAnalyticsOverviewCard(props: { /> ); } + +export function useContractAnalyticsOverview(props: { + chainId: number; + contractAddress: string; + startDate: Date; + endDate: Date; +}) { + const { chainId, contractAddress, startDate, endDate } = props; + const wallets = useContractUniqueWalletAnalytics({ + chainId: chainId, + contractAddress: contractAddress, + startDate, + endDate, + }); + + const transactions = useContractTransactionAnalytics({ + chainId: chainId, + contractAddress: contractAddress, + startDate, + endDate, + }); + + const events = useContractEventAnalytics({ + chainId: chainId, + contractAddress: contractAddress, + startDate, + endDate, + }); + + const isPending = + wallets.isPending || transactions.isPending || events.isPending; + + const { data, precision } = useMemo(() => { + if (isPending) { + return { + data: undefined, + precision: "day" as const, + }; + } + + const time = (wallets.data || transactions.data || events.data || []).map( + (wallet) => wallet.time, + ); + + // if the time difference between the first and last time is less than 3 days - use hour precision + const firstTime = time[0]; + const lastTime = time[time.length - 1]; + const timeDiff = + firstTime && lastTime + ? differenceInCalendarDays(lastTime, firstTime) + : undefined; + + const precision: "day" | "hour" = !timeDiff + ? "hour" + : timeDiff < 3 + ? "hour" + : "day"; + + return { + data: time.map((time) => { + const wallet = wallets.data?.find( + (wallet) => + getDateKey(wallet.time, precision) === getDateKey(time, precision), + ); + const transaction = transactions.data?.find( + (transaction) => + getDateKey(transaction.time, precision) === + getDateKey(time, precision), + ); + + const event = events.data?.find((event) => { + return ( + getDateKey(event.time, precision) === getDateKey(time, precision) + ); + }); + + return { + time, + wallets: wallet?.count || 0, + transactions: transaction?.count || 0, + events: event?.count || 0, + }; + }), + precision, + }; + }, [wallets.data, transactions.data, events.data, isPending]); + + return { + data, + precision, + isPending, + }; +} + +export function toolTipLabelFormatterWithPrecision(precision: "day" | "hour") { + return function toolTipLabelFormatter(_v: string, item: unknown) { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return formatDate( + new Date(time), + precision === "day" ? "MMM d, yyyy" : "MMM d, yyyy hh:mm a", + ); + } + return undefined; + }; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/permissions/shared-permissions-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/permissions/shared-permissions-page.tsx index 877506d5207..6bee04d58a6 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/permissions/shared-permissions-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/permissions/shared-permissions-page.tsx @@ -1,7 +1,9 @@ import { notFound } from "next/navigation"; import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +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 { ContractPermissionsPage } from "./ContractPermissionsPage"; import { ContractPermissionsPageClient } from "./ContractPermissionsPage.client"; @@ -21,6 +23,18 @@ export async function SharedPermissionsPage(props: { notFound(); } + // new public page can't show /permissions page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + const { clientContract, serverContract, isLocalhostChain } = info; if (isLocalhostChain) { return ( diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PageHeader.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PageHeader.tsx new file mode 100644 index 00000000000..9ab0b2c8073 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PageHeader.tsx @@ -0,0 +1,26 @@ +import { ToggleThemeButton } from "@/components/color-mode-toggle"; +import Link from "next/link"; +import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo"; +import { PublicPageConnectButton } from "./PublicPageConnectButton"; + +export function PageHeader() { + return ( +
+
+
+ + + + thirdweb + + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx new file mode 100644 index 00000000000..0e1aede509a --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { getSDKTheme } from "app/(app)/components/sdk-component-theme"; +import { useAllChainsData } from "hooks/chains/allChains"; +import { useTheme } from "next-themes"; +import { ConnectButton } from "thirdweb/react"; + +const client = getClientThirdwebClient(); + +export function PublicPageConnectButton(props: { + connectButtonClassName?: string; +}) { + const { theme } = useTheme(); + const t = theme === "light" ? "light" : "dark"; + const { allChainsV5 } = useAllChainsData(); + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.stories.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.stories.tsx new file mode 100644 index 00000000000..a2bd697d625 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.stories.tsx @@ -0,0 +1,199 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { storybookThirdwebClient } from "stories/utils"; +import { getContract } from "thirdweb"; +import type { ChainMetadata } from "thirdweb/chains"; +import { ThirdwebProvider } from "thirdweb/react"; +import { ContractHeaderUI } from "./ContractHeader"; + +const meta = { + title: "ERC20/ContractHeader", + component: ContractHeaderUI, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const mockTokenImage = + "ipfs://ipfs/QmXYgTEavjF6c9X1a2pt5E379MYqSwFzzKvsUbSnRiSUEc/ea207d218948137.67aa26cfbd956.png"; + +const ethereumChainMetadata: ChainMetadata = { + name: "Ethereum Mainnet", + chain: "ethereum", + chainId: 1, + networkId: 1, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + rpc: ["https://eth.llamarpc.com"], + shortName: "eth", + slug: "ethereum", + testnet: false, + icon: { + url: "https://thirdweb.com/chain-icons/ethereum.svg", + width: 24, + height: 24, + format: "svg", + }, + explorers: [ + { + name: "Etherscan", + url: "https://etherscan.io", + standard: "EIP3091", + }, + ], + stackType: "evm", +}; + +const mockContract = getContract({ + client: storybookThirdwebClient, + chain: { + id: 1, + name: "Ethereum", + rpc: "https://eth.llamarpc.com", + }, + address: "0x1234567890123456789012345678901234567890", +}); + +const mockSocialUrls = { + twitter: "https://twitter.com", + discord: "https://discord.gg", + telegram: "https://web.telegram.org/", + website: "https://example.com", + github: "https://github.com", + linkedin: "https://linkedin.com", + tiktok: "https://tiktok.com", + instagram: "https://instagram.com", + custom: "https://example.com", + reddit: "https://reddit.com", + youtube: "https://youtube.com", +}; + +export const WithImageAndMultipleSocialUrls: Story = { + args: { + name: "Sample Token", + symbol: "SMPL", + image: mockTokenImage, + chainMetadata: ethereumChainMetadata, + clientContract: mockContract, + socialUrls: { + twitter: mockSocialUrls.twitter, + discord: mockSocialUrls.discord, + telegram: mockSocialUrls.telegram, + website: mockSocialUrls.website, + github: mockSocialUrls.github, + }, + }, +}; + +export const WithBrokenImageAndSingleSocialUrl: Story = { + args: { + name: "Sample Token", + symbol: "SMPL", + image: "broken-image.png", + chainMetadata: ethereumChainMetadata, + clientContract: mockContract, + socialUrls: { + website: mockSocialUrls.website, + }, + }, +}; + +export const WithoutImageAndNoSocialUrls: Story = { + args: { + name: "Sample Token", + symbol: "SMPL", + image: undefined, + chainMetadata: ethereumChainMetadata, + clientContract: mockContract, + socialUrls: {}, + }, +}; + +export const LongNameAndLotsOfSocialUrls: Story = { + args: { + name: "This is a very long token name that should wrap to multiple lines", + symbol: "LONG", + image: "https://thirdweb.com/chain-icons/ethereum.svg", + chainMetadata: ethereumChainMetadata, + clientContract: mockContract, + socialUrls: { + twitter: mockSocialUrls.twitter, + discord: mockSocialUrls.discord, + telegram: mockSocialUrls.telegram, + reddit: mockSocialUrls.reddit, + youtube: mockSocialUrls.youtube, + website: mockSocialUrls.website, + github: mockSocialUrls.github, + }, + }, +}; + +export const AllSocialUrls: Story = { + args: { + name: "Sample Token", + symbol: "SMPL", + image: "https://thirdweb.com/chain-icons/ethereum.svg", + chainMetadata: ethereumChainMetadata, + clientContract: mockContract, + socialUrls: { + twitter: mockSocialUrls.twitter, + discord: mockSocialUrls.discord, + telegram: mockSocialUrls.telegram, + reddit: mockSocialUrls.reddit, + youtube: mockSocialUrls.youtube, + website: mockSocialUrls.website, + github: mockSocialUrls.github, + linkedin: mockSocialUrls.linkedin, + tiktok: mockSocialUrls.tiktok, + instagram: mockSocialUrls.instagram, + custom: mockSocialUrls.custom, + }, + }, +}; + +export const InvalidSocialUrls: Story = { + args: { + name: "Sample Token", + symbol: "SMPL", + image: "https://thirdweb.com/chain-icons/ethereum.svg", + chainMetadata: ethereumChainMetadata, + clientContract: mockContract, + socialUrls: { + twitter: "invalid-url", + discord: "invalid-url", + telegram: "invalid-url", + reddit: "", + youtube: mockSocialUrls.youtube, + }, + }, +}; + +export const SomeSocialUrls: Story = { + args: { + name: "Sample Token", + symbol: "SMPL", + image: "https://thirdweb.com/chain-icons/ethereum.svg", + chainMetadata: ethereumChainMetadata, + clientContract: mockContract, + socialUrls: { + website: mockSocialUrls.website, + twitter: mockSocialUrls.twitter, + }, + }, +}; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx new file mode 100644 index 00000000000..f5c02f413cf --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx @@ -0,0 +1,220 @@ +import { Img } from "@/components/blocks/Img"; +import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; +import { Button } from "@/components/ui/button"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler"; +import { cn } from "@/lib/utils"; +import { ChainIconClient } from "components/icons/ChainIcon"; +import { GithubIcon } from "components/icons/brand-icons/GithubIcon"; +import { InstagramIcon } from "components/icons/brand-icons/InstagramIcon"; +import { LinkedInIcon } from "components/icons/brand-icons/LinkedinIcon"; +import { RedditIcon } from "components/icons/brand-icons/RedditIcon"; +import { TiktokIcon } from "components/icons/brand-icons/TiktokIcon"; +import { XIcon as TwitterXIcon } from "components/icons/brand-icons/XIcon"; +import { YoutubeIcon } from "components/icons/brand-icons/YoutubeIcon"; +import { ExternalLinkIcon, GlobeIcon } from "lucide-react"; +import Link from "next/link"; +import { useMemo } from "react"; +import type { ThirdwebContract } from "thirdweb"; +import type { ChainMetadata } from "thirdweb/chains"; +import { DiscordIcon } from "../../../../../../../../../components/icons/brand-icons/DiscordIcon"; +import { TelegramIcon } from "../../../../../../../../../components/icons/brand-icons/TelegramIcon"; + +const platformToIcons: Record> = { + twitter: TwitterXIcon, + x: TwitterXIcon, + discord: DiscordIcon, + telegram: TelegramIcon, + reddit: RedditIcon, + website: GlobeIcon, + github: GithubIcon, + youtube: YoutubeIcon, + instagram: InstagramIcon, + tiktok: TiktokIcon, + linkedin: LinkedInIcon, +}; + +export function ContractHeaderUI(props: { + name: string; + symbol: string | undefined; + image: string | undefined; + chainMetadata: ChainMetadata; + clientContract: ThirdwebContract; + socialUrls: object; +}) { + const socialUrls = useMemo(() => { + const socialUrlsValue: { name: string; href: string }[] = []; + for (const [key, value] of Object.entries(props.socialUrls)) { + if ( + typeof value === "string" && + typeof key === "string" && + isValidUrl(value) + ) { + socialUrlsValue.push({ name: key, href: value }); + } + } + + return socialUrlsValue; + }, [props.socialUrls]); + + const cleanedChainName = props.chainMetadata?.name + ?.replace("Mainnet", "") + .trim(); + + const explorersToShow = getExplorersToShow(props.chainMetadata); + + return ( +
+ {props.image && ( + + {props.name[0]} +
+ } + /> + )} + +
+ {/* top row */} +
+
+

+ {props.name} +

+ +
+ + + {cleanedChainName && ( + {cleanedChainName} + )} + + + {socialUrls + .toSorted((a, b) => { + const aIcon = platformToIcons[a.name.toLowerCase()]; + const bIcon = platformToIcons[b.name.toLowerCase()]; + + if (aIcon && bIcon) { + return 0; + } + + if (aIcon) { + return -1; + } + + return 1; + }) + .map(({ name, href }) => ( + + ))} +
+
+
+ + {/* bottom row */} +
+ + + {explorersToShow?.map((validBlockExplorer) => ( + + ))} + + {/* TODO - render social links here */} +
+
+
+ ); +} + +function isValidUrl(url: string) { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +function getExplorersToShow(chainMetadata: ChainMetadata) { + const validBlockExplorers = chainMetadata.explorers + ?.filter((e) => e.standard === "EIP3091") + ?.slice(0, 2); + + return validBlockExplorers?.slice(0, 1); +} + +function BadgeLink(props: { + name: string; + href: string; +}) { + return ( + + ); +} + +function SocialLink(props: { + name: string; + href: string; + icon?: React.FC<{ className?: string }>; +}) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx new file mode 100644 index 00000000000..f384e7bc4e1 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useTheme } from "next-themes"; +import type { Chain, ThirdwebClient } from "thirdweb"; +import { PayEmbed } from "thirdweb/react"; +import { getSDKTheme } from "../../../../../../../components/sdk-component-theme"; + +export function BuyTokenEmbed(props: { + client: ThirdwebClient; + chain: Chain; + tokenSymbol: string; + tokenName: string; + tokenAddress: string; +}) { + const { theme } = useTheme(); + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx new file mode 100644 index 00000000000..3507c6e9a8a --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { differenceInCalendarDays, formatDate } from "date-fns"; +import { ArrowUpIcon, InfoIcon } from "lucide-react"; +import { ArrowDownIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useTokenPriceData } from "../_hooks/useTokenPriceData"; + +function PriceChartUI(props: { + isPending: boolean; + showTimeOfDay: boolean; + data: Array<{ + date: string; + price_usd: number; + price_usd_cents: number; + }>; +}) { + const data = props.data.map((item) => ({ + price: item.price_usd, + time: new Date(item.date).getTime(), + })); + + return ( + { + return tokenPriceUSDFormatter.format(value as number); + }} + /> + ); +} + +const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 10, + roundingMode: "halfEven", + notation: "compact", +}); + +const marketCapFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + roundingMode: "halfEven", + notation: "compact", +}); + +const holdersFormatter = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + notation: "compact", +}); + +const percentChangeFormatter = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + +function getTooltipLabelFormatter(includeTimeOfDay: boolean) { + return (_v: string, item: unknown) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return formatDate( + new Date(time), + includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy", + ); + } + return undefined; + }; +} + +export function TokenStats(params: { + chainId: number; + contractAddress: string; +}) { + const tokenPriceQuery = useTokenPriceData(params); + const [interval, setInterval] = useState("max"); + + const tokenPriceData = tokenPriceQuery.data; + + const filteredHistoricalPrices = useMemo(() => { + const currentDate = new Date(); + + if (tokenPriceData?.type === "no-data") { + return []; + } + + return tokenPriceData?.data?.historical_prices.filter((item) => { + const date = new Date(item.date); + const maxDiff = + interval === "24h" + ? 1 + : interval === "7d" + ? 7 + : interval === "30d" + ? 30 + : interval === "1y" + ? 365 + : Number.MAX_SAFE_INTEGER; + + return differenceInCalendarDays(currentDate, date) <= maxDiff; + }); + }, [tokenPriceData, interval]); + + const priceUsd = + tokenPriceData?.type === "no-data" || tokenPriceQuery.isError + ? "N/A" + : tokenPriceData?.data?.price_usd; + + const percentChange24h = + tokenPriceData?.type === "no-data" || tokenPriceQuery.isError + ? "N/A" + : tokenPriceData?.data?.percent_change_24h; + + const marketCap = + tokenPriceData?.type === "no-data" || tokenPriceQuery.isError + ? "N/A" + : tokenPriceData?.data?.market_cap_usd; + + const holders = + tokenPriceData?.type === "no-data" || tokenPriceQuery.isError + ? "N/A" + : tokenPriceData?.data?.holders; + + return ( +
+ {/* price and change */} +
+
+

Current Price

+
+ { + return ( +

+ {typeof v === "number" + ? tokenPriceUSDFormatter.format(v) + : v} +

+ ); + }} + /> + { + if (typeof data === "string") { + return null; + } + + const formattedAbsChange = percentChangeFormatter.format( + Math.abs(data), + ); + + const isAlmostZero = + formattedAbsChange === percentChangeFormatter.format(0); + + return ( + 0 + ? "success" + : "destructive" + } + className="gap-2 text-sm" + > +
+ {isAlmostZero ? null : data > 0 ? ( + + ) : ( + + )} + + {isAlmostZero ? "~0%" : `${formattedAbsChange}%`} + +
+ (1d) +
+ ); + }} + /> +
+
+ + +
+ +
+ + +
+ + + +
+
+ ); +} + +function TokenStat(props: { + value: T | undefined; + skeletonData: T; + label: string; + tooltip: string; +}) { + return ( +
+
+

{props.label}

+ + + +
+
+ { + return ( +

+ {v} +

+ ); + }} + /> +
+
+ ); +} + +type Interval = "24h" | "7d" | "30d" | "1y" | "max"; + +function IntervalSelector(props: { + interval: Interval; + setInterval: (timeframe: Interval) => void; +}) { + const intervals: Record = { + "24h": "1D", + "7d": "1W", + "30d": "1M", + "1y": "1Y", + max: "MAX", + }; + + return ( +
+ {Object.entries(intervals).map(([key, value]) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx new file mode 100644 index 00000000000..0a52fbc4ef6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx @@ -0,0 +1,237 @@ +"use client"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { formatDistanceToNow } from "date-fns"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ExternalLinkIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { type ThirdwebContract, toTokens } from "thirdweb"; +import type { ChainMetadata } from "thirdweb/chains"; +import { + type TokenTransfersData, + useTokenTransfers, +} from "../_hooks/useTokenTransfers"; + +const tokenAmountFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 3, + minimumFractionDigits: 0, +}); + +function RecentTransfersUI(props: { + data: TokenTransfersData[]; + tokenMetadata: { + decimals: number; + symbol: string; + }; + isPending: boolean; + rowsPerPage: number; + page: number; + setPage: (page: number) => void; + explorerUrl: string; +}) { + return ( +
+
+

+ Recent Transfers +

+

+ Track all token transfers with detailed information about senders, + recipients, amounts, and transaction timestamps +

+
+ + + + + + From + To + Amount + Time + Transaction + + + + {props.isPending + ? Array.from({ length: props.rowsPerPage }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + )) + : props.data.map((transfer) => ( + + + + + + + + +
+ + {tokenAmountFormatter.format( + Number( + toTokens( + BigInt(transfer.amount), + props.tokenMetadata.decimals, + ), + ), + )} + + + {props.tokenMetadata.symbol} + +
+
+ + {formatDistanceToNow(new Date(transfer.block_timestamp), { + addSuffix: true, + })} + + + + +
+ ))} +
+
+ + {props.data.length === 0 && !props.isPending && ( +
+

No transfers found

+
+ )} +
+ +
+ + +
+
+ ); +} + +function SkeletonRow() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export function RecentTransfers(props: { + clientContract: ThirdwebContract; + tokenSymbol: string; + chainMetadata: ChainMetadata; + decimals: number; +}) { + const rowsPerPage = 10; + const [page, setPage] = useState(0); + const [hasFetchedOnce, setHasFetchedOnce] = useState(false); + + const tokenQuery = useTokenTransfers({ + chainId: props.clientContract.chain.id, + contractAddress: props.clientContract.address, + page, + limit: rowsPerPage, + }); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!tokenQuery.isPending) { + setHasFetchedOnce(true); + } + }, [tokenQuery.isPending]); + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.stories.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.stories.tsx new file mode 100644 index 00000000000..8a8f0d9474b --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { storybookThirdwebClient } from "stories/utils"; +import { getContract } from "thirdweb"; +import { baseSepolia } from "thirdweb/chains"; +import { ThirdwebProvider } from "thirdweb/react"; +import { ClaimTokenCardUI } from "./claim-tokens-ui"; + +const meta = { + title: "ERC20/ClaimTokenCardUI", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + decorators: [ + (Story) => ( + +
+
+ +
+
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Variants: Story = { + args: {}, +}; + +const mockContract = getContract({ + client: storybookThirdwebClient, + chain: baseSepolia, + address: "0xD6866d1EcB82D37556B6cFEc0dFE8800D8b4B50A", +}); + +const claimConditionCurrency = { + address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + decimals: 18, + symbol: "ETH", +}; + +const mockClaimCondition = { + startTimestamp: 1747918865n, + maxClaimableSupply: + 115792089237316195423570985008687907853269984665640564039457584007913129639935n, + supplyClaimed: 790000000000000000000000n, + quantityLimitPerWallet: 0n, + merkleRoot: + "0x369b56a08dc68160042e86415132e683545596577b2f6afa272046c18cbab38b", + pricePerToken: 0n, + currency: claimConditionCurrency.address, + metadata: "ipfs://QmPgawkS1jYSudujQGzx2UbodZzNPbMgWto1LPEba1Pxpj/0", +}; + +function Story() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.tsx new file mode 100644 index 00000000000..152a64ac56c --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.tsx @@ -0,0 +1,368 @@ +"use client"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Label } from "@/components/ui/label"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { TransactionButton } from "components/buttons/TransactionButton"; +import { CheckIcon, CircleIcon, XIcon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + type ThirdwebContract, + padHex, + sendAndConfirmTransaction, + toTokens, + waitForReceipt, +} from "thirdweb"; +import type { ChainMetadata } from "thirdweb/chains"; +import { + claimTo, + type getActiveClaimCondition, + getApprovalForTransaction, +} from "thirdweb/extensions/erc20"; +import { useActiveAccount, useSendTransaction } from "thirdweb/react"; +import { getClaimParams } from "thirdweb/utils"; +import { tryCatch } from "utils/try-catch"; +import { DecimalInput } from "../../../../../../../../../../@/components/ui/decimal-input"; +import { getSDKTheme } from "../../../../../../../../components/sdk-component-theme"; +import { PublicPageConnectButton } from "../../../_components/PublicPageConnectButton"; +import { getCurrencyMeta } from "../../_utils/getCurrencyMeta"; + +type ActiveClaimCondition = Awaited>; + +// TODO UI improvements - show how many tokens connected wallet can claim at max + +export function ClaimTokenCardUI(props: { + contract: ThirdwebContract; + name: string; + symbol: string | undefined; + claimCondition: ActiveClaimCondition; + chainMetadata: ChainMetadata; + decimals: number; + claimConditionCurrency: { + decimals: number; + symbol: string; + }; +}) { + const [quantity, setQuantity] = useState("1"); + const account = useActiveAccount(); + const { theme } = useTheme(); + const sendClaimTx = useSendTransaction({ + payModal: { + theme: getSDKTheme(theme === "light" ? "light" : "dark"), + }, + }); + const [stepsUI, setStepsUI] = useState< + | undefined + | { + approve: undefined | "idle" | "pending" | "success" | "error"; + claim: "idle" | "pending" | "success" | "error"; + } + >(undefined); + + const approveAndClaim = useMutation({ + mutationFn: async () => { + if (!account) { + toast.error("Wallet is not connected"); + return; + } + + setStepsUI(undefined); + + const transaction = claimTo({ + contract: props.contract, + to: account.address, + quantity: String(quantity), + from: account.address, + }); + + const approveTx = await getApprovalForTransaction({ + transaction, + account, + }); + + if (approveTx) { + setStepsUI({ + approve: "pending", + claim: "idle", + }); + + const approveTxResult = await tryCatch( + sendAndConfirmTransaction({ + transaction: approveTx, + account, + }), + ); + + if (approveTxResult.error) { + setStepsUI({ + approve: "error", + claim: "idle", + }); + console.error(approveTxResult.error); + toast.error("Failed to approve spending", { + description: approveTxResult.error.message, + }); + return; + } + + setStepsUI({ + approve: "success", + claim: "pending", + }); + } + + async function sendAndConfirm() { + const result = await sendClaimTx.mutateAsync(transaction); + await waitForReceipt(result); + } + + setStepsUI({ + approve: approveTx ? "success" : undefined, + claim: "pending", + }); + + const claimTxResult = await tryCatch(sendAndConfirm()); + if (claimTxResult.error) { + setStepsUI({ + approve: approveTx ? "success" : undefined, + claim: "error", + }); + console.error(claimTxResult.error); + toast.error("Failed to claim tokens", { + description: claimTxResult.error.message, + }); + return; + } + + setStepsUI({ + approve: approveTx ? "success" : undefined, + claim: "success", + }); + + toast.success("Tokens claimed successfully"); + }, + }); + + const claimParamsQuery = useQuery({ + queryKey: ["claim-params", props.contract.address, account?.address], + queryFn: async () => { + const defaultPricing = { + pricePerTokenWei: props.claimCondition.pricePerToken, + currencyAddress: props.claimCondition.currency, + decimals: props.claimConditionCurrency.decimals, + symbol: props.claimConditionCurrency.symbol, + }; + + if (!account) { + return defaultPricing; + } + + const merkleRoot = props.claimCondition.merkleRoot; + if (!merkleRoot || merkleRoot === padHex("0x", { size: 32 })) { + return defaultPricing; + } + + const claimParams = await getClaimParams({ + contract: props.contract, + to: account.address, + quantity: 1n, // not relevant + type: "erc20", + tokenDecimals: props.decimals, + from: account.address, + }); + + const meta = await getCurrencyMeta({ + currencyAddress: claimParams.currency, + chainMetadata: props.chainMetadata, + chain: props.contract.chain, + client: props.contract.client, + }); + + return { + pricePerTokenWei: claimParams.pricePerToken, + currencyAddress: claimParams.currency, + decimals: meta.decimals, + symbol: meta.symbol, + }; + }, + }); + + const claimParamsData = claimParamsQuery.data; + + return ( +
+
+

Buy {props.symbol}

+

+ Buy tokens from the primary sale +

+
+ +
+
+
+ + + {/*

Maximum purchasable: {tokenData.maxPurchasable} tokens

*/} +
+ +
+ +
+ {/* Price per token */} +
+ Price per token + + { + return {v}; + }} + /> +
+ + {/* Quantity */} +
+ Quantity + {quantity} +
+ + {/* Total Price */} +
+
+ Total Price + { + return {v}; + }} + /> +
+
+
+ +
+ + {account ? ( + { + approveAndClaim.mutate(); + }} + variant="default" + className="!w-full" + txChainID={props.contract.chain.id} + disabled={approveAndClaim.isPending || !claimParamsData} + > + Buy + + ) : ( + + )} + + {/* only show steps if approval is required */} + {stepsUI?.approve && ( +
+

Status

+
+ {stepsUI.approve && ( + + )} + + {stepsUI.claim && ( + + )} +
+
+ )} +
+
+
+ ); +} + +type Status = "idle" | "pending" | "success" | "error"; + +const statusToIcon: Record> = { + pending: Spinner, + success: CheckIcon, + error: XIcon, + idle: CircleIcon, +}; + +function StepUI(props: { + title: string; + status: Status; +}) { + const Icon = statusToIcon[props.status]; + return ( +
+ +

{props.title}

+
+ ); +} + +function PriceInput(props: { + quantity: string; + setQuantity: (quantity: string) => void; + id: string; + symbol: string | undefined; +}) { + return ( +
+ { + console.log("value", value); + props.setQuantity(value); + }} + className="!text-2xl h-auto truncate bg-muted/50 pr-14 font-bold" + /> + {props.symbol && ( +
+ {props.symbol} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/contract-analytics/contract-analytics.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/contract-analytics/contract-analytics.tsx new file mode 100644 index 00000000000..297574dad0c --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/contract-analytics/contract-analytics.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; +import {} from "data/analytics/hooks"; +import { useState } from "react"; +import { + toolTipLabelFormatterWithPrecision, + useContractAnalyticsOverview, +} from "../../../../overview/components/Analytics"; + +export function ContractAnalyticsOverview(props: { + contractAddress: string; + chainId: number; + chainSlug: string; +}) { + const [startDate] = useState( + (() => { + const date = new Date(); + date.setDate(date.getDate() - 14); + return date; + })(), + ); + const [endDate] = useState(new Date()); + + const { data, precision, isPending } = useContractAnalyticsOverview({ + chainId: props.chainId, + contractAddress: props.contractAddress, + startDate, + endDate, + }); + + return ( +
+
+

Analytics

+

+ View trends in unique wallets, transactions, and events over time for + this contract +

+
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenPriceData.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenPriceData.ts new file mode 100644 index 00000000000..a3966a81057 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenPriceData.ts @@ -0,0 +1,56 @@ +import { isProd } from "@/constants/env-utils"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; +import { useQuery } from "@tanstack/react-query"; + +type TokenPriceData = { + price_usd: number; + price_usd_cents: number; + percent_change_24h: number; + market_cap_usd: number; + volume_24h_usd: number; + volume_change_24h: number; + holders: number; + historical_prices: Array<{ + date: string; + price_usd: number; + price_usd_cents: number; + }>; +}; + +export function useTokenPriceData(params: { + chainId: number; + contractAddress: string; +}) { + return useQuery({ + queryKey: ["token-price-chart", params.chainId, params.contractAddress], + retry: false, + retryOnMount: false, + refetchOnWindowFocus: false, + queryFn: async () => { + const url = new URL( + `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`, + ); + + url.searchParams.set("include_historical_prices", "true"); + url.searchParams.set("chain_id", params.chainId.toString()); + url.searchParams.set("address", params.contractAddress); + url.searchParams.set("include_holders", "true"); + url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(await res.text()); + } + + const json = await res.json(); + const priceData = json.data[0] as TokenPriceData | undefined; + return priceData + ? { + type: "data-found" as const, + data: priceData, + } + : { type: "no-data" as const }; + }, + refetchInterval: 5000, + }); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenTransfers.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenTransfers.ts new file mode 100644 index 00000000000..a4afca3d8c2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenTransfers.ts @@ -0,0 +1,54 @@ +import { isProd } from "@/constants/env-utils"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; +import { useQuery } from "@tanstack/react-query"; + +export type TokenTransfersData = { + from_address: string; + to_address: string; + contract_address: string; + block_number: string; + block_timestamp: string; + log_index: string; + transaction_hash: string; + transfer_type: string; + chain_id: number; + token_type: string; + amount: string; +}; + +export function useTokenTransfers(params: { + chainId: number; + contractAddress: string; + page: number; + limit: number; +}) { + return useQuery({ + queryKey: ["token-transfers", params], + retry: false, + retryOnMount: false, + refetchOnWindowFocus: false, + queryFn: async () => { + const domain = isProd ? "thirdweb" : "thirdweb-dev"; + const url = new URL( + `https://insight.${domain}.com/v1/tokens/transfers/${params.contractAddress}`, + ); + + url.searchParams.set("include_historical_prices", "true"); + url.searchParams.set("chain_id", params.chainId.toString()); + url.searchParams.set("include_holders", "true"); + url.searchParams.set("page", params.page.toString()); + url.searchParams.set("limit", params.limit.toString()); + url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(await res.text()); + } + + const json = await res.json(); + const data = json.data as TokenTransfersData[]; + return data; + }, + refetchInterval: 5000, + }); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_utils/getCurrencyMeta.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_utils/getCurrencyMeta.ts new file mode 100644 index 00000000000..9819e1a3b2c --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_utils/getCurrencyMeta.ts @@ -0,0 +1,44 @@ +import { type ThirdwebClient, getContract } from "thirdweb"; +import { NATIVE_TOKEN_ADDRESS } from "thirdweb"; +import { getAddress } from "thirdweb"; +import type { Chain, ChainMetadata } from "thirdweb/chains"; +import { symbol } from "thirdweb/extensions/common"; +import { decimals } from "thirdweb/extensions/erc20"; + +export async function getCurrencyMeta(params: { + currencyAddress: string; + chainMetadata: ChainMetadata; + chain: Chain; + client: ThirdwebClient; +}): Promise<{ + decimals: number; + symbol: string; +}> { + // if native token + if (getAddress(params.currencyAddress) === getAddress(NATIVE_TOKEN_ADDRESS)) { + return { + decimals: params.chainMetadata.nativeCurrency.decimals, + symbol: params.chainMetadata.nativeCurrency.symbol, + }; + } + + const currencyTokenContract = getContract({ + address: params.currencyAddress, + chain: params.chain, + client: params.client, + }); + + const [currencyDecimals, currencySymbol] = await Promise.all([ + decimals({ + contract: currencyTokenContract, + }), + symbol({ + contract: currencyTokenContract, + }), + ]); + + return { + decimals: currencyDecimals, + symbol: currencySymbol, + }; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx new file mode 100644 index 00000000000..dfb3b572fe6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx @@ -0,0 +1,175 @@ +import type { ThirdwebContract } from "thirdweb"; +import {} from "thirdweb"; +import type { ChainMetadata } from "thirdweb/chains"; +import { getContractMetadata } from "thirdweb/extensions/common"; +import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20"; +import { PageHeader } from "../_components/PageHeader"; +import { ContractHeaderUI } from "./_components/ContractHeader"; +import { BuyTokenEmbed } from "./_components/PayEmbedSection"; +import { TokenStats } from "./_components/PriceChart"; +import { RecentTransfers } from "./_components/RecentTransfers"; +import { ClaimTokenCardUI } from "./_components/claim-tokens/claim-tokens-ui"; +import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics"; +import { getCurrencyMeta } from "./_utils/getCurrencyMeta"; + +export async function ERC20PublicPage(props: { + serverContract: ThirdwebContract; + clientContract: ThirdwebContract; + chainMetadata: ChainMetadata; +}) { + const [contractMetadata, activeClaimCondition, tokenDecimals] = + await Promise.all([ + getContractMetadata({ + contract: props.serverContract, + }), + getActiveClaimConditionWithErrorHandler(props.serverContract), + decimals({ + contract: props.serverContract, + }), + ]); + + const claimConditionCurrencyMeta = activeClaimCondition + ? await getCurrencyMeta({ + currencyAddress: activeClaimCondition.currency, + chainMetadata: props.chainMetadata, + chain: props.serverContract.chain, + client: props.serverContract.client, + }).catch(() => undefined) + : undefined; + + const buyEmbed = ( + + ); + + return ( +
+ +
+ + +
+ +
+
+ {activeClaimCondition ? ( +
+ +
+ ) : ( + + )} + +
{buyEmbed}
+ + + + {!activeClaimCondition && ( + + )} +
+
+
{buyEmbed}
+
+
+
+
+ ); +} + +function BuyEmbed(props: { + clientContract: ThirdwebContract; + chainMetadata: ChainMetadata; + tokenDecimals: number; + tokenName: string; + tokenSymbol: string; + tokenAddress: string; + claimConditionMeta: + | { + activeClaimCondition: ActiveClaimCondition; + claimConditionCurrency: { + decimals: number; + symbol: string; + }; + } + | undefined; +}) { + if (!props.claimConditionMeta) { + return ( + + ); + } + + return ( + + ); +} + +async function getActiveClaimConditionWithErrorHandler( + contract: ThirdwebContract, +) { + try { + const activeClaimCondition = await getActiveClaimCondition({ contract }); + return activeClaimCondition; + } catch { + return undefined; + } +} + +type ActiveClaimCondition = Awaited>; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/shared-settings-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/shared-settings-page.tsx index a35d46be95e..3ee088bd2f7 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/shared-settings-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/settings/shared-settings-page.tsx @@ -3,8 +3,10 @@ import { notFound } from "next/navigation"; import type { ThirdwebContract } from "thirdweb"; import { getPlatformFeeInfo } from "thirdweb/extensions/common"; import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +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 { ContractSettingsPage } from "./ContractSettingsPage"; import { ContractSettingsPageClient } from "./ContractSettingsPage.client"; @@ -24,6 +26,18 @@ export async function SharedContractSettingsPage(props: { notFound(); } + // new public page can't show /settings page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + const { clientContract, serverContract, isLocalhostChain } = info; if (isLocalhostChain) { diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-layout.tsx index edea62ddeae..e4d69861f94 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-layout.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-layout.tsx @@ -11,6 +11,7 @@ import { NebulaChatButton } from "../../../../../nebula-app/(app)/components/Flo import { examplePrompts } from "../../../../../nebula-app/(app)/data/examplePrompts"; import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken"; import type { ProjectMeta } from "../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +import { TeamHeader } from "../../../../team/components/TeamHeader/team-header"; import { ConfigureCustomChain } from "./_layout/ConfigureCustomChain"; import { getContractMetadataHeaderData } from "./_layout/contract-metadata"; import { ContractPageLayout } from "./_layout/contract-page-layout"; @@ -19,6 +20,7 @@ import { supportedERCs } from "./_utils/detectedFeatures/supportedERCs"; import { getContractPageParamsInfo } from "./_utils/getContractFromParams"; import { getContractPageMetadata } from "./_utils/getContractPageMetadata"; import { getContractPageSidebarLinks } from "./_utils/getContractPageSidebarLinks"; +import { shouldRenderNewPublicPage } from "./_utils/newPublicPage"; export async function SharedContractLayout(props: { contractAddress: string; @@ -53,17 +55,27 @@ export async function SharedContractLayout(props: { if (isLocalhostChain) { return ( - - {props.children} - + + + {props.children} + + ); } + // if rendering new public page - do not render the old layout + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + return props.children; + } + } + const [ isValidContract, contractPageMetadata, @@ -99,31 +111,33 @@ Users may be considering integrating the contract into their applications. Discu The following is the user's message:`; return ( - - - {props.children} - + + + + {props.children} + + ); } @@ -216,3 +230,22 @@ export async function generateContractLayoutMetadata(params: { }; } } + +function ConditionalTeamHeaderLayout({ + children, + projectMeta, +}: { children: React.ReactNode; projectMeta: ProjectMeta | undefined }) { + // if inside a project page - do not another team header + if (projectMeta) { + return children; + } + + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-overview-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-overview-page.tsx index 3d3faf5e75a..74ab5df780f 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-overview-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-overview-page.tsx @@ -1,11 +1,18 @@ import { notFound } from "next/navigation"; import { ErrorBoundary } from "react-error-boundary"; +import type { ThirdwebContract } from "thirdweb"; +import type { ChainMetadata } from "thirdweb/chains"; import type { ProjectMeta } from "../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; import { getContractPageParamsInfo } from "./_utils/getContractFromParams"; import { getContractPageMetadata } from "./_utils/getContractPageMetadata"; +import { + type NewPublicPageType, + shouldRenderNewPublicPage, +} from "./_utils/newPublicPage"; import { ContractOverviewPage } from "./overview/ContractOverviewPage"; import { PublishedBy } from "./overview/components/published-by.server"; import { ContractOverviewPageClient } from "./overview/contract-overview-page.client"; +import { ERC20PublicPage } from "./public-pages/erc20/erc20"; export async function SharedContractOverviewPage(props: { contractAddress: string; @@ -24,6 +31,7 @@ export async function SharedContractOverviewPage(props: { const { clientContract, serverContract, chainMetadata, isLocalhostChain } = info; + if (isLocalhostChain) { return ( + ); + } + } + const contractPageMetadata = await getContractPageMetadata(serverContract); return ( @@ -59,3 +82,25 @@ export async function SharedContractOverviewPage(props: { /> ); } + +function RenderNewPublicContractPage(props: { + serverContract: ThirdwebContract; + clientContract: ThirdwebContract; + chainMetadata: ChainMetadata; + type: NewPublicPageType; +}) { + switch (props.type) { + case "erc20": { + return ( + + ); + } + default: { + return null; + } + } +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/shared-sources-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/shared-sources-page.tsx index 07f30aca14a..b1b54816993 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/shared-sources-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/shared-sources-page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils"; import { getContractPageParamsInfo } from "../_utils/getContractFromParams"; +import { shouldRenderNewPublicPage } from "../_utils/newPublicPage"; import { ContractSourcesPage } from "./ContractSourcesPage"; export async function SharedContractSourcesPage(props: { @@ -18,5 +20,17 @@ export async function SharedContractSourcesPage(props: { notFound(); } + // new public page can't show /sources page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + return ; } diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx index cf5e0f398b8..49c28f18dc3 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx @@ -4,8 +4,10 @@ import { isMintToSupported, } from "thirdweb/extensions/erc20"; import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types"; +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 { ContractTokensPage } from "./ContractTokensPage"; import { ContractTokensPageClient } from "./ContractTokensPage.client"; @@ -25,6 +27,18 @@ export async function SharedContractTokensPage(props: { notFound(); } + // new public page can't show /tokens page + if (!props.projectMeta) { + const shouldHide = await shouldRenderNewPublicPage(info.serverContract); + if (shouldHide) { + redirectToContractLandingPage({ + contractAddress: props.contractAddress, + chainIdOrSlug: props.chainIdOrSlug, + projectMeta: props.projectMeta, + }); + } + } + if (info.isLocalhostChain) { return ( +
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/chainlist/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/chainlist/layout.tsx new file mode 100644 index 00000000000..5e413be4947 --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/chainlist/layout.tsx @@ -0,0 +1,16 @@ +import { TeamHeader } from "../../../team/components/TeamHeader/team-header"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/contracts/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/contracts/layout.tsx new file mode 100644 index 00000000000..df8af93e28d --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/contracts/layout.tsx @@ -0,0 +1,16 @@ +import { TeamHeader } from "../../team/components/TeamHeader/team-header"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/explore/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/explore/layout.tsx new file mode 100644 index 00000000000..df8af93e28d --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/explore/layout.tsx @@ -0,0 +1,16 @@ +import { TeamHeader } from "../../team/components/TeamHeader/team-header"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/layout.tsx index 24f27ebc40c..bf871c68266 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/layout.tsx @@ -1,6 +1,5 @@ import { AppFooter } from "@/components/blocks/app-footer"; import { ErrorProvider } from "../../../contexts/error-handler"; -import { TeamHeader } from "../team/components/TeamHeader/team-header"; export default function DashboardLayout(props: { children: React.ReactNode; @@ -8,9 +7,6 @@ export default function DashboardLayout(props: { return (
-
- -
{props.children}
diff --git a/apps/dashboard/src/app/(app)/(dashboard)/profile/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/profile/layout.tsx new file mode 100644 index 00000000000..df8af93e28d --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/profile/layout.tsx @@ -0,0 +1,16 @@ +import { TeamHeader } from "../../team/components/TeamHeader/team-header"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/layout.tsx new file mode 100644 index 00000000000..df8af93e28d --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/layout.tsx @@ -0,0 +1,16 @@ +import { TeamHeader } from "../../team/components/TeamHeader/team-header"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/layout.tsx new file mode 100644 index 00000000000..df8af93e28d --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/support/layout.tsx @@ -0,0 +1,16 @@ +import { TeamHeader } from "../../team/components/TeamHeader/team-header"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/tools/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/tools/layout.tsx index d07391bb8c8..2db4b33569d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/tools/layout.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/tools/layout.tsx @@ -1,5 +1,6 @@ import { SidebarLayout } from "@/components/blocks/SidebarLayout"; import type { Metadata } from "next"; +import { TeamHeader } from "../../team/components/TeamHeader/team-header"; export const metadata: Metadata = { title: "thirdweb Blockchain Tools", @@ -11,31 +12,36 @@ export default function ToolLayout({ children, }: { children: React.ReactNode }) { return ( - - {children} - +
+
+ +
+ + {children} + +
); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page-impl.tsx index 5c3e728cbad..f704d2b8a84 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page-impl.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page-impl.tsx @@ -38,6 +38,8 @@ export function CreateTokenAssetPage(props: { client: ThirdwebClient; teamId: string; projectId: string; + teamSlug: string; + projectSlug: string; }) { const account = useActiveAccount(); const { idToChain } = useAllChainsData(); @@ -51,6 +53,14 @@ export function CreateTokenAssetPage(props: { throw new Error("No Connected Wallet"); } + trackEvent( + getTokenStepTrackingData({ + action: "deploy", + chainId: Number(formValues.chain), + status: "attempt", + }), + ); + trackEvent( getTokenDeploymentTrackingData("attempt", Number(formValues.chain)), ); @@ -90,6 +100,14 @@ export function CreateTokenAssetPage(props: { }, }); + trackEvent( + getTokenStepTrackingData({ + action: "deploy", + chainId: Number(formValues.chain), + status: "success", + }), + ); + trackEvent( getTokenDeploymentTrackingData("success", Number(formValues.chain)), ); @@ -110,6 +128,14 @@ export function CreateTokenAssetPage(props: { contractAddress: contractAddress, }; } catch (e) { + trackEvent( + getTokenStepTrackingData({ + action: "deploy", + chainId: Number(formValues.chain), + status: "error", + }), + ); + trackEvent( getTokenDeploymentTrackingData("error", Number(formValues.chain)), ); @@ -352,6 +378,8 @@ export function CreateTokenAssetPage(props: { { revalidatePathAction( `/team/${props.teamId}/project/${props.projectId}/assets`, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.client.tsx index cd9c5630636..10b6f27a971 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.client.tsx @@ -35,6 +35,8 @@ export function CreateTokenAssetPageUI(props: { client: ThirdwebClient; createTokenFunctions: CreateTokenFunctions; onLaunchSuccess: () => void; + teamSlug: string; + projectSlug: string; }) { const [step, setStep] = useState<"token-info" | "distribution" | "launch">( "token-info", @@ -114,6 +116,8 @@ export function CreateTokenAssetPageUI(props: { {step === "launch" && ( { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.stories.tsx index e40c3b0cd2d..6001789c432 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/create-token-page.stories.tsx @@ -41,6 +41,8 @@ const mockCreateTokenFunctions = { export const Default: Story = { args: { accountAddress: "0x1234567890123456789012345678901234567890", + teamSlug: "test-team", + projectSlug: "test-project", client: storybookThirdwebClient, createTokenFunctions: mockCreateTokenFunctions, onLaunchSuccess: () => {}, @@ -50,6 +52,8 @@ export const Default: Story = { export const ErrorOnDeploy: Story = { args: { accountAddress: "0x1234567890123456789012345678901234567890", + teamSlug: "test-team", + projectSlug: "test-project", client: storybookThirdwebClient, onLaunchSuccess: () => {}, createTokenFunctions: { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-sale.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-sale.tsx index 37d26407b2e..877720bed4c 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-sale.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-sale.tsx @@ -3,7 +3,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; import { TokenSelector } from "@/components/blocks/TokenSelector"; import { DynamicHeight } from "@/components/ui/DynamicHeight"; -import { Input } from "@/components/ui/input"; +import { DecimalInput } from "@/components/ui/decimal-input"; import { Switch } from "@/components/ui/switch"; import type { ThirdwebClient } from "thirdweb"; import type { TokenDistributionForm } from "../form"; @@ -112,38 +112,3 @@ export function TokenSaleSection(props: { ); } - -function DecimalInput(props: { - value: string; - onChange: (value: string) => void; - maxValue?: number; -}) { - return ( - { - const number = Number(e.target.value); - // ignore if string becomes invalid number - if (Number.isNaN(number)) { - return; - } - - if (props.maxValue && number > props.maxValue) { - return; - } - - // replace leading multiple zeros with single zero - let cleanedValue = e.target.value.replace(/^0+/, "0"); - - // replace leading zero before decimal point - if (!cleanedValue.includes(".")) { - cleanedValue = cleanedValue.replace(/^0+/, ""); - } - - props.onChange(cleanedValue || "0"); - }} - /> - ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/launch/launch-token.tsx index ad307554c2a..1a66ac53d2f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/launch/launch-token.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/launch/launch-token.tsx @@ -33,6 +33,8 @@ export function LaunchTokenStatus(props: { onPrevious: () => void; client: ThirdwebClient; onLaunchSuccess: () => void; + teamSlug: string; + projectSlug: string; }) { const formValues = props.values; const { createTokenFunctions } = props; @@ -100,7 +102,9 @@ export function LaunchTokenStatus(props: { retryLabel: "Failed to deploy contract", execute: createSequenceExecutorFn(0, async (values) => { const result = await createTokenFunctions.deployContract(values); - setContractLink(`/${values.chain}/${result.contractAddress}`); + setContractLink( + `/team/${props.teamSlug}/${props.projectSlug}/contract/${values.chain}/${result.contractAddress}`, + ); }), }, { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx index f0baa6e1e5a..c46db4a6bae 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx @@ -57,6 +57,8 @@ export default async function Page(props: { />
{data.length === 0 || data.every((d) => d[activeKey] === 0) ? ( - {emptyChartContent} + {emptyChartContent} ) : ( generateRandomData(), []); return ( @@ -30,35 +32,63 @@ export function EmptyChartState({ children }: { children?: React.ReactNode }) {
{children ?? "No data available"}
- +
); } export function LoadingChartState() { return ( -
- +
+
); } function SkeletonBarChart(props: { data: FakeCartData[]; + type: "bar" | "area"; }) { return ( - - - + {props.type === "bar" ? ( + + + + ) : ( + + + + + + + + + + )} ); } diff --git a/apps/dashboard/src/components/buttons/MismatchButton.tsx b/apps/dashboard/src/components/buttons/MismatchButton.tsx index ccd6e65976d..49738791678 100644 --- a/apps/dashboard/src/components/buttons/MismatchButton.tsx +++ b/apps/dashboard/src/components/buttons/MismatchButton.tsx @@ -80,13 +80,20 @@ type MistmatchButtonProps = React.ComponentProps & { txChainId: number; isLoggedIn: boolean; isPending: boolean; + checkBalance?: boolean; }; export const MismatchButton = forwardRef< HTMLButtonElement, MistmatchButtonProps >((props, ref) => { - const { txChainId, isLoggedIn, isPending, ...buttonProps } = props; + const { + txChainId, + isLoggedIn, + isPending, + checkBalance = true, + ...buttonProps + } = props; const account = useActiveAccount(); const wallet = useActiveWallet(); const activeWalletChain = useActiveWalletChain(); @@ -150,7 +157,8 @@ export const MismatchButton = forwardRef< } const isBalanceRequired = - wallet.id === "smart" ? false : !GAS_FREE_CHAINS.includes(txChainId); + checkBalance && + (wallet.id === "smart" ? false : !GAS_FREE_CHAINS.includes(txChainId)); const notEnoughBalance = (txChainBalance.data?.value || 0n) === 0n && isBalanceRequired; diff --git a/apps/dashboard/src/components/buttons/TransactionButton.tsx b/apps/dashboard/src/components/buttons/TransactionButton.tsx index fd3ad2c6036..6673d472dab 100644 --- a/apps/dashboard/src/components/buttons/TransactionButton.tsx +++ b/apps/dashboard/src/components/buttons/TransactionButton.tsx @@ -29,6 +29,7 @@ type TransactionButtonProps = Omit & { txChainID: number; variant?: "destructive" | "primary" | "default"; isLoggedIn: boolean; + checkBalance?: boolean; }; export const TransactionButton: React.FC = ({ @@ -38,6 +39,7 @@ export const TransactionButton: React.FC = ({ txChainID, variant, isLoggedIn, + checkBalance, ...restButtonProps }) => { const activeWallet = useActiveWallet(); @@ -68,6 +70,7 @@ export const TransactionButton: React.FC = ({ txChainId={txChainID} {...restButtonProps} disabled={disabled} + checkBalance={checkBalance} className={cn("relative overflow-hidden", restButtonProps.className)} style={{ paddingLeft: transactionCount diff --git a/apps/dashboard/src/components/contract-components/tables/contract-table.tsx b/apps/dashboard/src/components/contract-components/tables/contract-table.tsx index 1065844643a..5354efba474 100644 --- a/apps/dashboard/src/components/contract-components/tables/contract-table.tsx +++ b/apps/dashboard/src/components/contract-components/tables/contract-table.tsx @@ -156,7 +156,11 @@ export function ContractTableUI(props: { }} /> - Contract Address + {props.variant === "contract" && ( + Contract Address + )} + + {props.variant === "asset" && Asset Page} Actions @@ -193,14 +197,30 @@ export function ContractTableUI(props: { /> - - - + {props.variant === "contract" && ( + + + + )} + + {props.variant === "asset" && ( + + + + )} ) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/icons/brand-icons/TelegramIcon.tsx b/apps/dashboard/src/components/icons/brand-icons/TelegramIcon.tsx new file mode 100644 index 00000000000..f9cc12b4edb --- /dev/null +++ b/apps/dashboard/src/components/icons/brand-icons/TelegramIcon.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from "react"; + +export function TelegramIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/icons/brand-icons/XIcon.tsx b/apps/dashboard/src/components/icons/brand-icons/XIcon.tsx index 582ea65c087..786ca8117c2 100644 --- a/apps/dashboard/src/components/icons/brand-icons/XIcon.tsx +++ b/apps/dashboard/src/components/icons/brand-icons/XIcon.tsx @@ -3,16 +3,17 @@ import type { SVGProps } from "react"; export const XIcon = (props: SVGProps) => { return ( - X - + + + ); }; diff --git a/apps/dashboard/src/data/analytics/contract-event-breakdown.ts b/apps/dashboard/src/data/analytics/contract-event-breakdown.ts index d4a048395a0..3f2dc126832 100644 --- a/apps/dashboard/src/data/analytics/contract-event-breakdown.ts +++ b/apps/dashboard/src/data/analytics/contract-event-breakdown.ts @@ -1,5 +1,5 @@ import { getUnixTime } from "date-fns"; -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "../../@/constants/public-envs"; import { getVercelEnv } from "../../lib/vercel-utils"; type InsightAggregationEntry = { @@ -46,7 +46,7 @@ export async function getContractEventBreakdown(params: { `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, ); diff --git a/apps/dashboard/src/data/analytics/contract-events.ts b/apps/dashboard/src/data/analytics/contract-events.ts index 1afee3eca36..a2b3164d40e 100644 --- a/apps/dashboard/src/data/analytics/contract-events.ts +++ b/apps/dashboard/src/data/analytics/contract-events.ts @@ -1,5 +1,5 @@ import { getUnixTime } from "date-fns"; -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "../../@/constants/public-envs"; import { getVercelEnv } from "../../lib/vercel-utils"; // This is weird aggregation response type, this will be changed later in insight @@ -49,7 +49,7 @@ export async function getContractEventAnalytics(params: { `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, ); diff --git a/apps/dashboard/src/data/analytics/contract-function-breakdown.ts b/apps/dashboard/src/data/analytics/contract-function-breakdown.ts index f92b13c1bdd..92a47f22ec9 100644 --- a/apps/dashboard/src/data/analytics/contract-function-breakdown.ts +++ b/apps/dashboard/src/data/analytics/contract-function-breakdown.ts @@ -1,5 +1,5 @@ import { getUnixTime } from "date-fns"; -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "../../@/constants/public-envs"; import { getVercelEnv } from "../../lib/vercel-utils"; type InsightAggregationEntry = { @@ -46,7 +46,7 @@ export async function getContractFunctionBreakdown(params: { `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, ); diff --git a/apps/dashboard/src/data/analytics/contract-transactions.ts b/apps/dashboard/src/data/analytics/contract-transactions.ts index 7ba6188b050..eb5e840d231 100644 --- a/apps/dashboard/src/data/analytics/contract-transactions.ts +++ b/apps/dashboard/src/data/analytics/contract-transactions.ts @@ -1,4 +1,4 @@ -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; import { getUnixTime } from "date-fns"; import { getVercelEnv } from "../../lib/vercel-utils"; @@ -49,7 +49,7 @@ export async function getContractTransactionAnalytics(params: { `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, ); diff --git a/apps/dashboard/src/data/analytics/contract-wallet-analytics.ts b/apps/dashboard/src/data/analytics/contract-wallet-analytics.ts index 06fb7b97336..f85540fce67 100644 --- a/apps/dashboard/src/data/analytics/contract-wallet-analytics.ts +++ b/apps/dashboard/src/data/analytics/contract-wallet-analytics.ts @@ -1,5 +1,5 @@ import { getUnixTime } from "date-fns"; -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "../../@/constants/public-envs"; import { getVercelEnv } from "../../lib/vercel-utils"; // This is weird aggregation response type, this will be changed later in insight @@ -49,7 +49,7 @@ export async function getContractUniqueWalletAnalytics(params: { `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, ); diff --git a/apps/dashboard/src/data/analytics/total-contract-events.ts b/apps/dashboard/src/data/analytics/total-contract-events.ts index 57632b97f4e..119e6443c43 100644 --- a/apps/dashboard/src/data/analytics/total-contract-events.ts +++ b/apps/dashboard/src/data/analytics/total-contract-events.ts @@ -1,4 +1,4 @@ -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; import { getVercelEnv } from "../../lib/vercel-utils"; // This is weird aggregation response type, this will be changed later in insight @@ -28,7 +28,7 @@ export async function getTotalContractEvents(params: { `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, ); diff --git a/apps/dashboard/src/data/analytics/total-contract-transactions.ts b/apps/dashboard/src/data/analytics/total-contract-transactions.ts index 22c7504d04d..4fda3c35c38 100644 --- a/apps/dashboard/src/data/analytics/total-contract-transactions.ts +++ b/apps/dashboard/src/data/analytics/total-contract-transactions.ts @@ -1,4 +1,4 @@ -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "../../@/constants/public-envs"; import { getVercelEnv } from "../../lib/vercel-utils"; // This is weird aggregation response type, this will be changed later in insight @@ -28,7 +28,7 @@ export async function getTotalContractTransactions(params: { `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, ); diff --git a/apps/dashboard/src/data/analytics/total-unique-wallets.ts b/apps/dashboard/src/data/analytics/total-unique-wallets.ts index 4006ce4e719..a2cb75b0b10 100644 --- a/apps/dashboard/src/data/analytics/total-unique-wallets.ts +++ b/apps/dashboard/src/data/analytics/total-unique-wallets.ts @@ -1,4 +1,4 @@ -import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "@/constants/public-envs"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; import { getVercelEnv } from "../../lib/vercel-utils"; // This is weird aggregation response type, this will be changed later in insight @@ -28,7 +28,7 @@ export async function getTotalContractUniqueWallets(params: { `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, { headers: { - "x-client-id": NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, }, }, );