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 (
+