From 3b1e16c8b709f61ab4ab751e5be14fc438f4e4ce Mon Sep 17 00:00:00 2001 From: MananTank Date: Thu, 29 May 2025 23:03:08 +0000 Subject: [PATCH] [TOOL-4621] New ERC20 public contract page (#7177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR primarily focuses on enhancing the dashboard's functionality and user interface by updating environment variable usage, improving component layouts, and refining analytics features. It also introduces new components and modifies existing ones for better data handling and presentation. ### Detailed summary - Added `NEXT_PUBLIC_DASHBOARD_CLIENT_ID` to `public-envs.ts`. - Updated `CreateTokenAssetPage` to pass `teamSlug` and `projectSlug`. - Changed `projectSlug` to use `project.slug` in `marketplace` page. - Enhanced `EmptyChartState` to accept a `type` prop. - Updated `getTokenStepTrackingData` to include "deploy" action. - Introduced `LoadingDots` component for loading states. - Refactored multiple layout components to include `TeamHeader`. - Added new public page handling logic in various components. - Updated analytics functions to utilize the new client ID. - Improved error handling and response management in analytics API calls. - Added `DecimalInput` component to replace standard input in `token-sale.tsx`. > The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-layout.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.stories.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/Analytics.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.tsx` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit - **New Features** - Added new layout components with team headers for various dashboard sections. - Introduced `LoadingDots` component for animated loading indicators. - Added `PublicPageConnectButton` and `PageHeader` components for public pages. - Implemented public ERC-20 token page with price charts, token stats, recent transfers, and buy token embed. - Added token claim card UI with transaction step feedback for purchasing tokens. - Added hooks for fetching token price data and token transfers. - Added utility to fetch currency metadata for tokens. - Added comprehensive contract analytics overview component. - Introduced `DecimalInput` component for validated decimal input handling. - Added new social media brand icons for Discord and Telegram. - **Improvements** - Enhanced area and bar charts with customizable styles, tooltip formatting, and explicit empty state types. - Improved empty chart state visuals with new area chart gradients and replaced spinner with animated dots. - Unified environment variable usage for client IDs across API calls and client initializations. - Updated contract overview to display call-to-action banner linking to public asset pages for ERC-20 tokens. - Enhanced contract table to conditionally show contract address or asset page links based on context. - Improved primary dashboard button to link to asset pages when applicable. - Added redirects to public landing pages for unsupported contract views lacking project metadata. - Wrapped existing layouts with conditional team headers for consistent UI in public/private views. - Added pagination and detailed tables for recent token transfers. - Updated project slug usage in marketplace listings for accuracy. - Introduced dynamic time precision handling in analytics charts and tooltips. - Added optional balance check control to transaction and mismatch buttons. - Replaced local decimal input component with shared validated decimal input component. - Added optional target attribute for call-to-action links in upsell banners. - Refined accessibility and sizing of icon components. - **Bug Fixes** - Fixed environment variable naming for client IDs to ensure consistent API integration. - **Refactor** - Standardized header and layout components for a unified dashboard experience. - Replaced spinner with animated dots in loading states for smoother UX. --- apps/dashboard/src/@/actions/getWalletNFTs.ts | 4 +- .../@/components/blocks/UpsellBannerCard.tsx | 10 +- .../@/components/blocks/charts/area-chart.tsx | 15 +- .../@/components/blocks/charts/bar-chart.tsx | 4 +- .../src/@/components/ui/LoadingDots.tsx | 10 + .../src/@/components/ui/decimal-input.tsx | 35 ++ apps/dashboard/src/@/constants/public-envs.ts | 2 +- .../src/@/constants/thirdweb.server.ts | 4 +- .../app/(app)/(dashboard)/(bridge)/layout.tsx | 16 + .../components/client/live-stats.tsx | 4 +- .../(chain)/[chain_id]/(chainPage)/layout.tsx | 8 +- .../_layout/metadata-header.tsx | 30 +- .../_layout/primary-dashboard-button.tsx | 12 +- .../[contractAddress]/_utils/newPublicPage.ts | 31 ++ .../analytics/shared-analytics-page.tsx | 13 + .../shared-claim-conditions-page.tsx | 13 + .../code/shared-code-page.tsx | 14 + .../cross-chain/shared-cross-chain-page.tsx | 14 + .../events/shared-events-page.tsx | 14 + .../explorer/shared-explorer-page.tsx | 14 + .../overview/ContractOverviewPage.tsx | 18 + .../overview/components/Analytics.tsx | 169 +++++--- .../permissions/shared-permissions-page.tsx | 14 + .../public-pages/_components/PageHeader.tsx | 26 ++ .../_components/PublicPageConnectButton.tsx | 40 ++ .../_components/ContractHeader.stories.tsx | 199 ++++++++++ .../erc20/_components/ContractHeader.tsx | 220 +++++++++++ .../erc20/_components/PayEmbedSection.tsx | 46 +++ .../erc20/_components/PriceChart.tsx | 314 +++++++++++++++ .../erc20/_components/RecentTransfers.tsx | 237 +++++++++++ .../claim-tokens/claim-tokens-ui.stories.tsx | 86 ++++ .../claim-tokens/claim-tokens-ui.tsx | 368 ++++++++++++++++++ .../contract-analytics/contract-analytics.tsx | 67 ++++ .../erc20/_hooks/useTokenPriceData.ts | 56 +++ .../erc20/_hooks/useTokenTransfers.ts | 54 +++ .../erc20/_utils/getCurrencyMeta.ts | 44 +++ .../public-pages/erc20/erc20.tsx | 175 +++++++++ .../settings/shared-settings-page.tsx | 14 + .../[contractAddress]/shared-layout.tsx | 99 +++-- .../shared-overview-page.tsx | 45 +++ .../sources/shared-sources-page.tsx | 14 + .../[contractAddress]/tokens/shared-page.tsx | 14 + .../(chain)/[chain_id]/tx/layout.tsx | 16 + .../(dashboard)/(chain)/chainlist/layout.tsx | 16 + .../(app)/(dashboard)/contracts/layout.tsx | 16 + .../app/(app)/(dashboard)/explore/layout.tsx | 16 + .../src/app/(app)/(dashboard)/layout.tsx | 4 - .../app/(app)/(dashboard)/profile/layout.tsx | 16 + .../(dashboard)/published-contract/layout.tsx | 16 + .../app/(app)/(dashboard)/support/layout.tsx | 16 + .../app/(app)/(dashboard)/tools/layout.tsx | 58 +-- .../assets/create/create-token-page-impl.tsx | 28 ++ .../create/create-token-page.client.tsx | 4 + .../create/create-token-page.stories.tsx | 4 + .../assets/create/distribution/token-sale.tsx | 37 +- .../assets/create/launch/launch-token.tsx | 6 +- .../(sidebar)/assets/create/page.tsx | 2 + .../(sidebar)/assets/create/tracking.ts | 2 +- .../(marketplace)/direct-listings/page.tsx | 2 +- .../team/components/Analytics/BarChart.tsx | 2 +- apps/dashboard/src/app/bridge/constants.ts | 4 +- .../CustomChat/CustomChatButton.tsx | 4 +- apps/dashboard/src/app/pay/constants.ts | 4 +- .../analytics/empty-chart-state.tsx | 60 ++- .../src/components/buttons/MismatchButton.tsx | 12 +- .../components/buttons/TransactionButton.tsx | 3 + .../tables/contract-table.tsx | 38 +- .../icons/brand-icons/DiscordIcon.tsx | 28 ++ .../icons/brand-icons/TelegramIcon.tsx | 17 + .../components/icons/brand-icons/XIcon.tsx | 15 +- .../analytics/contract-event-breakdown.ts | 4 +- .../src/data/analytics/contract-events.ts | 4 +- .../analytics/contract-function-breakdown.ts | 4 +- .../data/analytics/contract-transactions.ts | 4 +- .../analytics/contract-wallet-analytics.ts | 4 +- .../data/analytics/total-contract-events.ts | 4 +- .../analytics/total-contract-transactions.ts | 4 +- .../data/analytics/total-unique-wallets.ts | 4 +- 78 files changed, 2820 insertions(+), 244 deletions(-) create mode 100644 apps/dashboard/src/@/components/ui/LoadingDots.tsx create mode 100644 apps/dashboard/src/@/components/ui/decimal-input.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(bridge)/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/newPublicPage.ts create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PageHeader.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/ContractHeader.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PriceChart.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/claim-tokens/claim-tokens-ui.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/contract-analytics/contract-analytics.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenPriceData.ts create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_hooks/useTokenTransfers.ts create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_utils/getCurrencyMeta.ts create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/tx/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/chainlist/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/contracts/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/explore/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/profile/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/published-contract/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/support/layout.tsx create mode 100644 apps/dashboard/src/components/icons/brand-icons/DiscordIcon.tsx create mode 100644 apps/dashboard/src/components/icons/brand-icons/TelegramIcon.tsx 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, }, }, );