diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6ae0e9e9fb6..7ab9fc2b4a6 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -63,7 +63,7 @@ jobs: - name: Setup Biome uses: biomejs/setup-biome@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1 with: - version: 2.0.4 + version: 2.0.6 - run: pnpm lint diff --git a/apps/dashboard/.storybook/main.ts b/apps/dashboard/.storybook/main.ts index d11f0b46808..33bed7699d2 100644 --- a/apps/dashboard/.storybook/main.ts +++ b/apps/dashboard/.storybook/main.ts @@ -9,13 +9,15 @@ function getAbsolutePath(value: string): string { return dirname(require.resolve(join(value, "package.json"))); } const config: StorybookConfig = { - stories: ["../src/**/*.stories.tsx"], addons: [ getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-links"), getAbsolutePath("@chromatic-com/storybook"), getAbsolutePath("@storybook/addon-docs"), ], + features: { + experimentalRSC: true, + }, framework: { name: getAbsolutePath("@storybook/nextjs"), options: {}, @@ -26,8 +28,6 @@ const config: StorybookConfig = { }, }, staticDirs: ["../public"], - features: { - experimentalRSC: true, - }, + stories: ["../src/**/*.stories.tsx"], }; export default config; diff --git a/apps/dashboard/.storybook/preview.tsx b/apps/dashboard/.storybook/preview.tsx index 7b1658d6275..0927fa3f863 100644 --- a/apps/dashboard/.storybook/preview.tsx +++ b/apps/dashboard/.storybook/preview.tsx @@ -2,11 +2,10 @@ import type { Preview } from "@storybook/nextjs"; import "../src/global.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MoonIcon, SunIcon } from "lucide-react"; -import { ThemeProvider, useTheme } from "next-themes"; import { Inter as interFont } from "next/font/google"; +import { ThemeProvider, useTheme } from "next-themes"; // biome-ignore lint/style/useImportType: -import React from "react"; -import { useEffect } from "react"; +import React, { useEffect } from "react"; import { Toaster } from "sonner"; import { Button } from "../src/@/components/ui/button"; @@ -18,45 +17,33 @@ const fontSans = interFont({ }); const customViewports = { - xs: { - // Regular sized phones (iphone 15 / 15 pro) - name: "iPhone", - styles: { - width: "390px", - height: "844px", - }, - }, sm: { // Larger phones (iphone 15 plus / 15 pro max) name: "iPhone Plus", styles: { - width: "430px", height: "932px", + width: "430px", }, }, -}; - -const preview: Preview = { - parameters: { - viewport: { - viewports: customViewports, - }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, + xs: { + // Regular sized phones (iphone 15 / 15 pro) + name: "iPhone", + styles: { + height: "844px", + width: "390px", }, }, +}; +const preview: Preview = { decorators: [ (Story) => { return ( @@ -65,13 +52,22 @@ const preview: Preview = { ); }, ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + viewport: { + viewports: customViewports, + }, + }, }; export default preview; -function StoryLayout(props: { - children: React.ReactNode; -}) { +function StoryLayout(props: { children: React.ReactNode }) { const { setTheme, theme } = useTheme(); useEffect(() => { @@ -83,10 +79,10 @@ function StoryLayout(props: {
- + ) : props.cta && "onClick" in props.cta ? ( + + + ) : null} ); diff --git a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx index 17958a9b3f9..91e13fac006 100644 --- a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx +++ b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx @@ -279,7 +279,14 @@ function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) { // subnav if ("subMenu" in link) { return ( - + ); } diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx index fa37f0f1149..33b6002e809 100644 --- a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx @@ -18,7 +18,7 @@ export type MultiStepState = { } | { type: "error"; - message: React.ReactNode; + message: string; }; label: string; description?: string; @@ -27,6 +27,10 @@ export type MultiStepState = { export function MultiStepStatus(props: { steps: MultiStepState[]; onRetry: (step: MultiStepState) => void; + renderError?: ( + step: MultiStepState, + errorMessage: string, + ) => React.ReactNode; }) { return ( @@ -66,22 +70,24 @@ export function MultiStepStatus(props: {

)} - {step.status.type === "error" && ( -
-

- {step.status.message} -

- -
- )} + {step.status.type === "error" + ? props.renderError?.(step, step.status.message) || ( +
+

+ {step.status.message} +

+ +
+ ) + : null} ))} diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx index 4d3e6c91b64..56349f9982b 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */ "use client"; import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; @@ -190,7 +191,6 @@ export const SelectWithSearch = React.forwardRef< ref={ i === optionsToShow.length - 1 ? lastItemRef : undefined } - // biome-ignore lint/a11y/useSemanticElements: TDOO role="option" variant="ghost" > diff --git a/apps/dashboard/src/@/components/blocks/wallet-address.tsx b/apps/dashboard/src/@/components/blocks/wallet-address.tsx index 1703458514c..94c90837c8d 100644 --- a/apps/dashboard/src/@/components/blocks/wallet-address.tsx +++ b/apps/dashboard/src/@/components/blocks/wallet-address.tsx @@ -59,7 +59,11 @@ export function WalletAddress(props: { // special case for zero address if (address === ZERO_ADDRESS) { - return {shortenedAddress}; + return ( + + {shortenedAddress} + + ); } return ( diff --git a/apps/dashboard/src/@/components/connect-wallet/index.tsx b/apps/dashboard/src/@/components/connect-wallet/index.tsx index b1246e03732..308f5826723 100644 --- a/apps/dashboard/src/@/components/connect-wallet/index.tsx +++ b/apps/dashboard/src/@/components/connect-wallet/index.tsx @@ -120,20 +120,18 @@ export const CustomConnectWallet = (props: { if ((!isLoggedIn || !account) && loginRequired) { return ( - <> - - + Connect Wallet + + ); } diff --git a/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx b/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx index 5d5dc08e15e..dd01631d2a7 100644 --- a/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx +++ b/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx @@ -3,7 +3,8 @@ import type { StaticImageData } from "next/image"; import Image from "next/image"; import type { FetchDeployMetadataResult } from "thirdweb/contract"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; import { replaceIpfsUrl } from "@/lib/sdk"; import generalContractIcon from "../../../../../public/assets/tw-icons/general.png"; @@ -26,7 +27,17 @@ export const ContractIdImage: React.FC = ({ ); } 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 27ba135d3fd..84f8e6e8f02 100644 --- a/apps/dashboard/src/@/components/contract-components/tables/contract-table.tsx +++ b/apps/dashboard/src/@/components/contract-components/tables/contract-table.tsx @@ -314,7 +314,7 @@ const NetworkFilterCell = React.memo(function NetworkFilterCell({ client: ThirdwebClient; }) { if (chainIds.length < 2) { - return <> NETWORK ; + return "NETWORK"; } return ( diff --git a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx index 850504a947a..01b23707ae3 100644 --- a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx +++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx @@ -40,10 +40,7 @@ import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; import { toArrFromList } from "@/utils/string"; const ALL_PROJECT_SERVICES = SERVICES.filter( - (srv) => - srv.name !== "relayer" && - srv.name !== "chainsaw" && - srv.name !== "engineCloud", // TODO enable once API server is out + (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", ); export type CreateProjectPrefillOptions = { diff --git a/apps/dashboard/src/@/constants/thirdweb-client.server.ts b/apps/dashboard/src/@/constants/thirdweb-client.server.ts index 4c804ba4707..a474d286a07 100644 --- a/apps/dashboard/src/@/constants/thirdweb-client.server.ts +++ b/apps/dashboard/src/@/constants/thirdweb-client.server.ts @@ -3,7 +3,9 @@ import "server-only"; import { DASHBOARD_THIRDWEB_SECRET_KEY } from "./server-envs"; import { getConfiguredThirdwebClient } from "./thirdweb.server"; +// During build time, the secret key might not be available +// Create a client that will work for build but may fail at runtime if secret key is needed export const serverThirdwebClient = getConfiguredThirdwebClient({ - secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY || "dummy-build-time-secret", teamId: undefined, }); diff --git a/apps/dashboard/src/@/constants/thirdweb.server.ts b/apps/dashboard/src/@/constants/thirdweb.server.ts index cfa24f269bf..cd7aab19f8b 100644 --- a/apps/dashboard/src/@/constants/thirdweb.server.ts +++ b/apps/dashboard/src/@/constants/thirdweb.server.ts @@ -76,14 +76,18 @@ export function getConfiguredThirdwebClient(options: { }); } + // During build time, provide fallbacks if credentials are missing + const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client"; + const secretKey = options.secretKey || undefined; + return createThirdwebClient({ - clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID, + clientId: clientId, config: { storage: { gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL, }, }, - secretKey: options.secretKey, + secretKey: secretKey, teamId: options.teamId, }); } diff --git a/apps/dashboard/src/@/icons/ChainIcon.tsx b/apps/dashboard/src/@/icons/ChainIcon.tsx index ae845a75915..fef285a6635 100644 --- a/apps/dashboard/src/@/icons/ChainIcon.tsx +++ b/apps/dashboard/src/@/icons/ChainIcon.tsx @@ -30,7 +30,7 @@ export const ChainIconClient = ({ fallback={} key={resolvedSrc} loading={restProps.loading || "lazy"} - skeleton={
} + skeleton={} src={resolvedSrc} /> ); diff --git a/apps/dashboard/src/@/storybook/stubs.ts b/apps/dashboard/src/@/storybook/stubs.ts index 0d02432b5a8..1308233de4e 100644 --- a/apps/dashboard/src/@/storybook/stubs.ts +++ b/apps/dashboard/src/@/storybook/stubs.ts @@ -192,6 +192,7 @@ export function teamSubscriptionsStub( currentPeriodEnd: "2024-12-15T20:56:06.000Z", currentPeriodStart: "2024-11-15T20:56:06.000Z", id: "sub-1", + skus: [], status: "active", trialEnd: overrides?.trialEnd || null, trialStart: null, @@ -212,6 +213,7 @@ export function teamSubscriptionsStub( currentPeriodEnd: "2024-12-15T20:56:06.000Z", currentPeriodStart: "2024-11-15T20:56:15.000Z", id: "sub-2", + skus: [], status: "active", trialEnd: null, trialStart: null, @@ -229,25 +231,19 @@ export function teamSubscriptionsStub( // In-App Wallets { amount: usage.inAppWalletAmount?.amount || 0, - description: `${ - usage.inAppWalletAmount?.quantity || 0 - } x In-App Wallets (Tier 1 at $0.00 / month)`, + description: `${usage.inAppWalletAmount?.quantity || 0} x In-App Wallets (Tier 1 at $0.00 / month)`, thirdwebSku: "usage:in_app_wallet", }, // AA Sponsorship { amount: usage.aaSponsorshipAmount?.amount || 0, - description: `${ - usage.aaSponsorshipAmount?.quantity || 0 - } x AA Gas Sponsorship (at $0.011 / month)`, + description: `${usage.aaSponsorshipAmount?.quantity || 0} x AA Gas Sponsorship (at $0.011 / month)`, thirdwebSku: "usage:aa_sponsorship", }, // OP Grant { amount: usage.aaSponsorshipOpGrantAmount?.amount || 0, - description: `${ - usage.aaSponsorshipOpGrantAmount?.quantity || 0 - } x AA Gas Sponsorship (OP) (at $0.011 / month)`, + description: `${usage.aaSponsorshipOpGrantAmount?.quantity || 0} x AA Gas Sponsorship (OP) (at $0.011 / month)`, thirdwebSku: "usage:aa_sponsorship_op_grant", }, ], diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index f1f2e743920..70015ea7cd5 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -39,12 +39,6 @@ export interface TransactionStats { count: number; } -export interface RpcMethodStats { - date: string; - evmMethod: string; - count: number; -} - export interface EngineCloudStats { date: string; chainId: string; @@ -72,6 +66,21 @@ export interface UniversalBridgeWalletStats { developerFeeUsdCents: number; } +export interface WebhookRequestStats { + date: string; + webhookId: string; + httpStatusCode: number; + totalRequests: number; +} + +export interface WebhookLatencyStats { + date: string; + webhookId: string; + p50LatencyMs: number; + p90LatencyMs: number; + p99LatencyMs: number; +} + export interface WebhookSummaryStats { webhookId: string; totalRequests: number; @@ -79,7 +88,7 @@ export interface WebhookSummaryStats { errorRequests: number; successRate: number; avgLatencyMs: number; - errorBreakdown: Record; + errorBreakdown: Record; } export interface AnalyticsQueryParams { @@ -88,4 +97,5 @@ export interface AnalyticsQueryParams { from?: Date; to?: Date; period?: "day" | "week" | "month" | "year" | "all"; + limit?: number; } diff --git a/apps/dashboard/src/@/types/billing.ts b/apps/dashboard/src/@/types/billing.ts index 992af032853..cfa6f45df3d 100644 --- a/apps/dashboard/src/@/types/billing.ts +++ b/apps/dashboard/src/@/types/billing.ts @@ -14,3 +14,8 @@ export type ProductSKU = | "usage:aa_sponsorship" | "usage:aa_sponsorship_op_grant" | null; + +export type ChainInfraSKU = + | "chain:infra:rpc" + | "chain:infra:insight" + | "chain:infra:account_abstraction"; diff --git a/apps/dashboard/src/@/utils/pricing.tsx b/apps/dashboard/src/@/utils/pricing.tsx index c961ec4b867..400b38560d7 100644 --- a/apps/dashboard/src/@/utils/pricing.tsx +++ b/apps/dashboard/src/@/utils/pricing.tsx @@ -23,7 +23,6 @@ export const TEAM_PLANS: Record< features: [ "Email Support", "48hr Guaranteed Response", - "Invite Team Members", "Custom In-App Wallet Auth", ], price: 99, diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx index 9e0627c9bb3..2cb32f6689a 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-card.tsx @@ -1,5 +1,7 @@ -import { defineChain } from "thirdweb"; -import { getChainMetadata } from "thirdweb/chains"; +import { ExternalLinkIcon } from "lucide-react"; +import Link from "next/link"; +import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb"; +import { type ChainMetadata, getChainMetadata } from "thirdweb/chains"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; @@ -53,7 +55,7 @@ export async function RouteListCard({ return (
- +
{resolvedOriginTokenIconUri ? ( @@ -80,32 +82,60 @@ export async function RouteListCard({ - - - - - - - - - - - -
- {originTokenName === "ETH" - ? originChain.nativeCurrency.name - : originTokenName} - - {originChain.name} -
- {destinationTokenName === "ETH" - ? destinationChain.nativeCurrency.name - : destinationTokenName} - - {destinationChain.name} -
+
+
+ +
+ {originChain.name} +
+
+ +
+ +
+ {destinationChain.name} +
+
+
); } + +const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); + +function TokenName(props: { + tokenAddress: string; + tokenName: string; + chainMetadata: ChainMetadata; +}) { + const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress; + + if (isERC20) { + return ( + + {props.tokenName} + + + ); + } + + return ( +
+ {props.chainMetadata.nativeCurrency.name} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx index 3eb209216a7..86a975c5eac 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routelist-row.tsx @@ -1,5 +1,14 @@ -import { defineChain, getChainMetadata } from "thirdweb/chains"; -import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { ExternalLinkIcon } from "lucide-react"; +import Link from "next/link"; +import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb"; +import { + type ChainMetadata, + defineChain, + getChainMetadata, +} from "thirdweb/chains"; +import { shortenAddress } from "thirdweb/utils"; +import { Img } from "@/components/blocks/Img"; +import { Button } from "@/components/ui/button"; import { TableCell, TableRow } from "@/components/ui/table"; import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; @@ -52,37 +61,14 @@ export async function RouteListRow({ ]); return ( - + -
-
- {resolvedOriginTokenIconUri ? ( - // For now we're using a normal img tag because the domain for these images is unknown - // eslint-disable-next-line @next/next/no-img-element - {originTokenAddress} - ) : ( -
- )} - {originTokenSymbol && ( - - )} -
-
+ @@ -90,34 +76,12 @@ export async function RouteListRow({ -
-
- {resolvedDestinationTokenIconUri ? ( - // eslint-disable-next-line @next/next/no-img-element - {destinationTokenAddress} - ) : ( -
- )} - {destinationTokenSymbol && ( - - )} -
-
+ @@ -126,3 +90,47 @@ export async function RouteListRow({ ); } + +const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); + +function TokenInfo(props: { + tokenAddress: string; + tokenSymbol: string | undefined; + chainMetadata: ChainMetadata; + tokenIconUri: string | undefined; +}) { + const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress; + + return ( +
+ {props.tokenIconUri ? ( + {props.tokenAddress} + ) : ( +
+ )} + {isERC20 ? ( + + ) : ( + + {props.chainMetadata.nativeCurrency.symbol} + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx index 701da89643c..b8c2ce213a4 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/components/server/routes-table.tsx @@ -86,22 +86,14 @@ export async function RoutesData(props: {

No Results found

) : props.activeView === "table" ? ( - + - - - - Origin Token - - - Origin Chain - - - Destination Token - - - Destination Chain - + + + Origin Token + Origin Chain + Destination Token + Destination Chain diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx index 95dfb9cfae2..3b7c24ac2e0 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/opengraph-image.tsx @@ -1,7 +1,8 @@ import { ImageResponse } from "next/og"; import { useId } from "react"; import { download } from "thirdweb/storage"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; import { fetchChain } from "@/utils/fetchChain"; // Route segment config @@ -81,16 +82,29 @@ export default async function Image({ fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) => res.arrayBuffer(), ), - // download the chain icon if there is one - chain.icon?.url && hasWorkingChainIcon - ? download({ - client: serverThirdwebClient, - uri: chain.icon.url, - }).then((res) => res.arrayBuffer()) + // download the chain icon if there is one and secret key is available + chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY + ? (async () => { + try { + const client = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); + const response = await download({ + client, + uri: chain.icon?.url || "", + }); + return response.arrayBuffer(); + } catch (error) { + // If download fails, return undefined to fallback to no icon + console.warn("Failed to download chain icon:", error); + return undefined; + } + })() : undefined, // download the background image (based on chain) fetch( - chain.icon?.url && hasWorkingChainIcon + chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY ? new URL( "og-lib/assets/chain/bg-with-icon.png", @@ -118,7 +132,7 @@ export default async function Image({ /> {/* the actual component starts here */} - {hasWorkingChainIcon && ( + {hasWorkingChainIcon && chainIcon && ( = ({ // biome-ignore lint/suspicious/noArrayIndexKey: FIXME key={rowIndex} onClick={() => setTokenRow(row.original)} - // biome-ignore lint/a11y/useSemanticElements: FIXME role="group" style={{ cursor: "pointer" }} > diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx index 2f425175ea6..dd54ade9fc7 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/index.tsx @@ -130,6 +130,7 @@ const getClaimConditionTypeFromPhase = ( if (phase.snapshot) { if ( + phase.maxClaimablePerWallet?.toString() === "0" && phase.price === "0" && typeof phase.snapshot !== "string" && phase.snapshot.length === 1 && @@ -464,204 +465,198 @@ export const ClaimConditionsForm: React.FC = ({ } return ( - <> - - - {/* Show the reason why the form is disabled */} - {!isAdmin && ( - Connect with admin wallet to edit claim conditions. - )} - {controlledFields.map((field, index) => { - const dropType: DropType = field.snapshot - ? field.maxClaimablePerWallet?.toString() === "0" - ? "specific" - : "overrides" - : "any"; - - const claimConditionType = getClaimConditionTypeFromPhase(field); - - const isActive = activePhaseId === field.id; - - const snapshotValue = field.snapshot?.map((v) => - typeof v === "string" - ? { - address: v, - currencyAddress: ZERO_ADDRESS, - maxClaimable: "unlimited", - price: "unlimited", - } - : { - ...v, - currencyAddress: v?.currencyAddress || ZERO_ADDRESS, - maxClaimable: v?.maxClaimable?.toString() || "unlimited", - price: v?.price?.toString() || "unlimited", - }, - ); - - return ( - - { - setOpenSnapshotIndex(-1); + + + {/* Show the reason why the form is disabled */} + {!isAdmin && ( + Connect with admin wallet to edit claim conditions. + )} + {controlledFields.map((field, index) => { + const dropType: DropType = field.snapshot + ? field.maxClaimablePerWallet?.toString() === "0" + ? "specific" + : "overrides" + : "any"; + + const claimConditionType = getClaimConditionTypeFromPhase(field); + + const isActive = activePhaseId === field.id; + + const snapshotValue = field.snapshot?.map((v) => + typeof v === "string" + ? { + address: v, + currencyAddress: ZERO_ADDRESS, + maxClaimable: "unlimited", + price: "unlimited", + } + : { + ...v, + currencyAddress: v?.currencyAddress || ZERO_ADDRESS, + maxClaimable: v?.maxClaimable?.toString() || "unlimited", + price: v?.price?.toString() || "unlimited", + }, + ); + + return ( + + { + setOpenSnapshotIndex(-1); + }} + setSnapshot={(snapshot) => + form.setValue(`phases.${index}.snapshot`, snapshot) + } + value={snapshotValue} + /> + + + { + removePhase(index); }} - setSnapshot={(snapshot) => - form.setValue(`phases.${index}.snapshot`, snapshot) - } - value={snapshotValue} /> - - + + ); + })} + + {phases?.length === 0 && ( + + +
+ + {isMultiPhase + ? "Missing Claim Phases" + : "Missing Claim Conditions"} + + + {isMultiPhase + ? "You need to set at least one claim phase for people to claim this drop." + : "You need to set claim conditions for people to claim this drop."} + +
+
+ )} + + +
+ + + 0) + } + leftIcon={} + size="sm" + variant={phases?.length > 0 ? "outline" : "solid"} > - { - removePhase(index); - }} - /> - - - ); - })} - - {phases?.length === 0 && ( - - -
- - {isMultiPhase - ? "Missing Claim Phases" - : "Missing Claim Conditions"} - - - {isMultiPhase - ? "You need to set at least one claim phase for people to claim this drop." - : "You need to set claim conditions for people to claim this drop."} - -
-
- )} - - -
- - - 0) + Add {isMultiPhase ? "Phase" : "Claim Conditions"} + + + {Object.keys(ClaimConditionTypeData).map((key) => { + const type = key as ClaimConditionType; + + if (type === "custom") { + return null; } - leftIcon={} - size="sm" - variant={phases?.length > 0 ? "outline" : "solid"} - > - Add {isMultiPhase ? "Phase" : "Claim Conditions"} - - - {Object.keys(ClaimConditionTypeData).map((key) => { - const type = key as ClaimConditionType; - - if (type === "custom") { - return null; - } - - return ( - { - addPhase(type); - // TODO: Automatically start editing the new phase after adding it - }} - > -
- {ClaimConditionTypeData[type].name} - - {ClaimConditionTypeData[type].description} - - } - /> -
-
- ); - })} -
-
-
- - {controlledFields.some((field) => field.fromSdk) && ( - - )} -
-
- }> - - {(hasRemovedPhases || hasAddedPhases) && ( - - You have unsaved changes - - )} - {controlledFields.length > 0 || - hasRemovedPhases || - !isMultiPhase ? ( - - {claimConditionsQuery.isPending - ? "Saving Phases" - : "Save Phases"} - - ) : null} - - -
-
+ return ( + { + addPhase(type); + // TODO: Automatically start editing the new phase after adding it + }} + > +
+ {ClaimConditionTypeData[type].name} + + {ClaimConditionTypeData[type].description} + + } + /> +
+
+ ); + })} + +
+
+ + {controlledFields.some((field) => field.fromSdk) && ( + + )} +
+ +
+ }> + + {(hasRemovedPhases || hasAddedPhases) && ( + + You have unsaved changes + + )} + {controlledFields.length > 0 || + hasRemovedPhases || + !isMultiPhase ? ( + + {claimConditionsQuery.isPending + ? "Saving Phases" + : "Save Phases"} + + ) : null} + + +
- +
); }; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts index c2102b9f139..e390eab95d5 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams.ts @@ -1,6 +1,7 @@ import { getAddress, getContract, isAddress } from "thirdweb"; import { localhost } from "thirdweb/chains"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; import { mapV4ChainToV5Chain } from "@/utils/map-chains"; import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token"; import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides"; @@ -18,13 +19,21 @@ export async function getContractPageParamsInfo(params: { return undefined; } - // attempt to get the auth token + // Create server client only if secret key is available + if (!DASHBOARD_THIRDWEB_SECRET_KEY) { + return undefined; + } + + const serverClient = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); const serverContract = getContract({ address: contractAddress, // eslint-disable-next-line no-restricted-syntax chain: mapV4ChainToV5Chain(chainMetadata), - client: serverThirdwebClient, + client: serverClient, }); const clientContract = getContract({ diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx index db58bcc64cb..0440851b8ad 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/table.tsx @@ -1,4 +1,5 @@ /** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */ +/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */ "use client"; import { @@ -320,7 +321,6 @@ export const NFTGetAllTable: React.FC = ({ }} opacity={failedToLoad ? 0.3 : 1} pointerEvents={failedToLoad ? "none" : "auto"} - // biome-ignore lint/a11y/useSemanticElements: FIXME role="group" style={{ cursor: "pointer" }} > diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx index 9a4e67ed3b2..4b1a24a1464 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/getContractCreator.tsx @@ -9,19 +9,23 @@ export async function getContractCreator( contract: ThirdwebContract, functionSelectors: string[], ) { - if (isOwnerSupported(functionSelectors)) { - return owner({ - contract, - }); - } + try { + if (isOwnerSupported(functionSelectors)) { + return await owner({ + contract, + }); + } - if (isGetRoleAdminSupported(functionSelectors)) { - return getRoleMember({ - contract, - index: BigInt(0), - role: "admin", - }); - } + if (isGetRoleAdminSupported(functionSelectors)) { + return await getRoleMember({ + contract, + index: BigInt(0), + role: "admin", + }); + } - return null; + return null; + } catch { + return null; + } } 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 index 8d2ac72f937..28bb90f2014 100644 --- 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 @@ -1,7 +1,7 @@ import { ExternalLinkIcon, GlobeIcon, Settings2Icon } from "lucide-react"; import Link from "next/link"; import { useMemo } from "react"; -import type { ThirdwebContract } from "thirdweb"; +import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; import { Img } from "@/components/blocks/Img"; import { Button } from "@/components/ui/button"; @@ -149,7 +149,7 @@ export function ContractHeaderUI(props: { {/* bottom row */}
- {props.contractCreator && ( + {props.contractCreator && props.contractCreator !== ZERO_ADDRESS && ( diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx index 6eec90fc0fe..29a25c98779 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx @@ -43,94 +43,92 @@ export const TokenAirdropForm: React.FC = ({ ); return ( - <> -
-
{ - try { - const tx = transferBatch({ - batch: data.addresses - .filter((address) => address.quantity !== undefined) - .map((address) => ({ - amount: address.quantity, - to: address.address, - })), - contract, - }); - await sendTransaction.mutateAsync(tx, { - onError: (error) => { - console.error(error); - }, - onSuccess: () => { - // Close the sheet/modal on success - if (toggle) { - toggle(false); - } - }, - }); - airdropNotifications.onSuccess(); - } catch (err) { - airdropNotifications.onError(err); - console.error(err); - } - })} - > -
- {airdropFormOpen ? ( - setAirdropFormOpen(false)} - setAirdrop={(value) => - setValue("addresses", value, { shouldDirty: true }) +
+ { + try { + const tx = transferBatch({ + batch: data.addresses + .filter((address) => address.quantity !== undefined) + .map((address) => ({ + amount: address.quantity, + to: address.address, + })), + contract, + }); + await sendTransaction.mutateAsync(tx, { + onError: (error) => { + console.error(error); + }, + onSuccess: () => { + // Close the sheet/modal on success + if (toggle) { + toggle(false); } - /> - ) : ( -
- - {addresses.length > 0 && ( -
- - - {addresses.length} addresses ready to be - airdropped - -
- )} -
- )} -
- {addresses?.length > 0 && !airdropFormOpen && ( - <> - {estimateGasCost && ( - - This transaction requires at least {estimateGasCost} gas. - Since each chain has a different gas limit, please split this - operation into multiple transactions if necessary. Usually - under 10M gas is safe. - - )} - +
+ {airdropFormOpen ? ( + setAirdropFormOpen(false)} + setAirdrop={(value) => + setValue("addresses", value, { shouldDirty: true }) + } + /> + ) : ( +
+ + {addresses.length > 0 && ( +
+ + + {addresses.length} addresses ready to be + airdropped + +
+ )} +
)} - -
- +
+ {addresses?.length > 0 && !airdropFormOpen && ( + <> + {estimateGasCost && ( + + This transaction requires at least {estimateGasCost} gas. Since + each chain has a different gas limit, please split this + operation into multiple transactions if necessary. Usually under + 10M gas is safe. + + )} + + Airdrop + + + )} + +
); }; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx index e73a1e0fa7d..0532102f79c 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/chain-icon.tsx @@ -1,7 +1,7 @@ /* eslint-disable @next/next/no-img-element */ import "server-only"; import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; import { cn } from "@/lib/utils"; import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; import { fallbackChainIcon } from "../../../../../../@/utils/chain-icons"; @@ -13,10 +13,16 @@ export async function ChainIcon(props: { if (props.iconUrl) { let imageLink = fallbackChainIcon; - const resolved = resolveSchemeWithErrorHandler({ - client: serverThirdwebClient, - uri: props.iconUrl, - }); + // Only resolve if we have a secret key available + const resolved = DASHBOARD_THIRDWEB_SECRET_KEY + ? resolveSchemeWithErrorHandler({ + client: getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }), + uri: props.iconUrl, + }) + : null; if (resolved) { // check if it loads or not diff --git a/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx b/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx index d9d11c088f7..95b1ac200d6 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/explore/components/contract-row/index.tsx @@ -5,7 +5,7 @@ import { Suspense } from "react"; import { ContractCard, ContractCardSkeleton, -} from "../../../../../../@/components/contracts/contract-card"; +} from "@/components/contracts/contract-card"; interface ContractRowProps { category: ExploreCategory; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx index dad810fd28e..bd17dc6536c 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/components/PublishedContractTable.tsx @@ -182,19 +182,17 @@ function ContractTableRow(props: { row: Row }) { const { row } = props; const { key, ...rowProps } = row.getRowProps(); return ( - <> - - {row.cells.map((cell) => ( - - {cell.render("Cell")} - - ))} - - + + {row.cells.map((cell) => ( + + {cell.render("Cell")} + + ))} + ); } diff --git a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx index 33fa5734563..1939d42bcfd 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/profile/[addressOrEns]/opengraph-image.tsx @@ -2,7 +2,8 @@ import { notFound } from "next/navigation"; import { ImageResponse } from "next/og"; import { resolveAvatar } from "thirdweb/extensions/ens"; import { GradientBlobbie } from "@/components/blocks/avatar/GradientBlobbie"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; /* eslint-disable @next/next/no-img-element */ import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; import { shortenIfAddress } from "@/utils/usedapp-external"; @@ -23,10 +24,18 @@ type PageProps = { export default async function Image(props: PageProps) { const params = await props.params; - const resolvedInfo = await resolveAddressAndEns( - params.addressOrEns, - serverThirdwebClient, - ); + + // Create client only if secret key is available + if (!DASHBOARD_THIRDWEB_SECRET_KEY) { + notFound(); + } + + const client = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); + + const resolvedInfo = await resolveAddressAndEns(params.addressOrEns, client); if (!resolvedInfo) { notFound(); @@ -43,14 +52,14 @@ export default async function Image(props: PageProps) { const ensImage = resolvedInfo.ensName ? await resolveAvatar({ - client: serverThirdwebClient, + client, name: resolvedInfo.ensName, }) : null; const resolvedENSImageSrc = ensImage ? resolveSchemeWithErrorHandler({ - client: serverThirdwebClient, + client, uri: ensImage, }) : null; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx index c40d6423d44..403e99d931d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/opengraph-image.tsx @@ -1,6 +1,7 @@ import { format } from "date-fns"; import { getSocialProfiles } from "thirdweb/social"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; import { resolveEns } from "@/lib/ens"; import { correctAndUniqueLicenses } from "@/lib/licenses"; import { getPublishedContractsWithPublisherMapping } from "../utils/getPublishedContractsWithPublisherMapping"; @@ -22,17 +23,25 @@ export default async function Image(props: { }) { const { publisher, contract_id } = props.params; + // Create client only if secret key is available + if (!DASHBOARD_THIRDWEB_SECRET_KEY) { + return null; + } + + const client = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); + const [publishedContracts, socialProfiles] = await Promise.all([ getPublishedContractsWithPublisherMapping({ - client: serverThirdwebClient, + client, contract_id: contract_id, publisher: publisher, }), getSocialProfiles({ - address: - (await resolveEns(publisher, serverThirdwebClient)).address || - publisher, - client: serverThirdwebClient, + address: (await resolveEns(publisher, client)).address || publisher, + client, }), ]); diff --git a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx index 96d1fbb0754..6da7c5e92e0 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/opengraph-image.tsx @@ -1,6 +1,7 @@ import { format } from "date-fns"; import { getSocialProfiles } from "thirdweb/social"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; import { resolveEns } from "@/lib/ens"; import { correctAndUniqueLicenses } from "@/lib/licenses"; import { getLatestPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping"; @@ -21,17 +22,25 @@ export default async function Image(props: { }) { const { publisher, contract_id } = props.params; + // Create client only if secret key is available + if (!DASHBOARD_THIRDWEB_SECRET_KEY) { + return null; + } + + const client = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); + const [publishedContract, socialProfiles] = await Promise.all([ getLatestPublishedContractsWithPublisherMapping({ - client: serverThirdwebClient, + client, contract_id: contract_id, publisher: publisher, }), getSocialProfiles({ - address: - (await resolveEns(publisher, serverThirdwebClient)).address || - publisher, - client: serverThirdwebClient, + address: (await resolveEns(publisher, client)).address || publisher, + client, }), ]); diff --git a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx index 37173a1b24c..9f1a86bae46 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/published-contract/[publisher]/[contract_id]/utils/publishedContractOGImageTemplate.tsx @@ -4,7 +4,8 @@ import { ImageResponse } from "next/og"; import { isAddress } from "thirdweb"; import { download } from "thirdweb/storage"; import { shortenAddress } from "thirdweb/utils"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; const OgBrandIcon: React.FC = () => ( // biome-ignore lint/a11y/noSvgWithoutTitle: not needed @@ -187,17 +188,41 @@ export async function publishedContractOGImageTemplate(params: { ibmPlexMono500_, ibmPlexMono700_, image, - params.logo - ? download({ - client: serverThirdwebClient, - uri: params.logo, - }).then((res) => res.arrayBuffer()) + params.logo && DASHBOARD_THIRDWEB_SECRET_KEY + ? (async () => { + try { + const client = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); + const response = await download({ + client, + uri: params.logo || "", + }); + return response.arrayBuffer(); + } catch (error) { + console.warn("Failed to download logo:", error); + return undefined; + } + })() : undefined, - params.publisherAvatar - ? download({ - client: serverThirdwebClient, - uri: params.publisherAvatar, - }).then((res) => res.arrayBuffer()) + params.publisherAvatar && DASHBOARD_THIRDWEB_SECRET_KEY + ? (async () => { + try { + const client = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); + const response = await download({ + client, + uri: params.publisherAvatar || "", + }); + return response.arrayBuffer(); + } catch (error) { + console.warn("Failed to download avatar:", error); + return undefined; + } + })() : undefined, ]); diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx index 1144797cdb8..fdea3a49fbb 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput.tsx @@ -21,37 +21,33 @@ export const SupportForm_SelectInput = (props: Props) => { const { options, formLabel, name, required, promptText } = props; return ( - <> -
- +
+ - -
- + +
); }; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx index 59420732905..32db19a1465 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx @@ -27,34 +27,32 @@ export const SupportForm_TeamSelection = (props: Props) => { const teamId = useId(); return ( - <> -
- +
+ - -
- + +
); }; diff --git a/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx b/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx index 5da2e639ba0..645b4e8fd4c 100644 --- a/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx +++ b/apps/dashboard/src/app/(app)/drops/[slug]/opengraph-image.tsx @@ -1,7 +1,8 @@ import { ImageResponse } from "next/og"; import { useId } from "react"; import { download } from "thirdweb/storage"; -import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; import { fetchChain } from "@/utils/fetchChain"; import { DROP_PAGES } from "./data"; @@ -84,16 +85,29 @@ export default async function Image({ params }: { params: { slug: string } }) { fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) => res.arrayBuffer(), ), - // download the chain icon if there is one - chain.icon?.url && hasWorkingChainIcon - ? download({ - client: serverThirdwebClient, - uri: chain.icon.url, - }).then((res) => res.arrayBuffer()) + // download the chain icon if there is one and secret key is available + chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY + ? (async () => { + try { + const client = getConfiguredThirdwebClient({ + secretKey: DASHBOARD_THIRDWEB_SECRET_KEY, + teamId: undefined, + }); + const response = await download({ + client, + uri: chain.icon?.url || "", + }); + return response.arrayBuffer(); + } catch (error) { + // If download fails, return undefined to fallback to no icon + console.warn("Failed to download chain icon:", error); + return undefined; + } + })() : undefined, // download the background image (based on chain) fetch( - chain.icon?.url && hasWorkingChainIcon + chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY ? new URL( "og-lib/assets/chain/bg-with-icon.png", @@ -121,7 +135,7 @@ export default async function Image({ params }: { params: { slug: string } }) { /> {/* the actual component starts here */} - {hasWorkingChainIcon && ( + {hasWorkingChainIcon && chainIcon && ( - - - - diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx index 76af32a734b..bbd88fd1942 100644 --- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx +++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx @@ -87,8 +87,7 @@ export function InviteTeamMembersUI(props: { client={props.client} customCTASection={
- {(props.team.billingPlan === "free" || - props.team.billingPlan === "starter") && ( + {props.team.billingPlan === "free" && (
a.chainId - b.chainId, + )} ecosystems={ecosystems.map((ecosystem) => ({ name: ecosystem.name, slug: ecosystem.slug, @@ -90,20 +120,3 @@ export default async function TeamLayout(props: { ); } - -async function fetchEcosystemList(teamId: string, authToken: string) { - const res = await fetch( - `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamId}/ecosystem-wallet`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - }, - ); - - if (!res.ok) { - return []; - } - - return (await res.json()).result as Ecosystem[]; -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx index 733259536e5..ac356034326 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/billing/layout.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; import { getTeamBySlug } from "@/api/team"; -import { TabPathLinks } from "../../../../../../../@/components/ui/tabs"; +import { TabPathLinks } from "@/components/ui/tabs"; import { loginRedirect } from "../../../../../login/loginRedirect"; export default async function Layout(props: { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx index a9300402db8..be107eeedf9 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx @@ -1,10 +1,10 @@ import { getEcosystemWalletUsage } from "@/api/analytics"; +import type { Partner } from "@/api/ecosystems"; import { getLastNDaysRange, type Range, } from "@/components/analytics/date-range-selector"; import { RangeSelector } from "@/components/analytics/range-selector"; -import type { Partner } from "../../../../types"; import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard"; import { EcosystemWalletsSummary } from "./Summary"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx index 22f9da796c7..fc8596a2ada 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; import { useMemo } from "react"; +import type { Partner } from "@/api/ecosystems"; import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; import { DocLink } from "@/components/blocks/DocLink"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; @@ -10,7 +11,6 @@ import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon"; import { UnityIcon } from "@/icons/brand-icons/UnityIcon"; import type { EcosystemWalletStats } from "@/types/analytics"; import { formatTickerNumber } from "@/utils/format-utils"; -import type { Partner } from "../../../../types"; type ChartData = Record & { time: string; // human readable date diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx index 80f30feac97..18beeb8a73b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { fetchEcosystem } from "@/api/ecosystems"; import { getTeamBySlug } from "@/api/team"; -import { getAuthToken } from "../../../../../../../../../../@/api/auth-token"; -import { fetchEcosystem } from "../../../utils/fetchEcosystem"; import { fetchPartners } from "../configuration/hooks/fetchPartners"; import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage"; @@ -29,7 +29,7 @@ export default async function Page(props: { } const [ecosystem, team] = await Promise.all([ - fetchEcosystem(params.slug, authToken, params.team_slug), + fetchEcosystem(params.slug, params.team_slug), getTeamBySlug(params.team_slug), ]); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx index 2eab1880cab..d8ce5386c2e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx @@ -1,9 +1,9 @@ import { redirect } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; +import { fetchEcosystem } from "@/api/ecosystems"; import { getTeamBySlug } from "@/api/team"; import { TabPathLinks } from "@/components/ui/tabs"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { fetchEcosystem } from "../../../utils/fetchEcosystem"; import { EcosystemHeader } from "./ecosystem-header.client"; export async function EcosystemLayoutSlug({ @@ -21,11 +21,7 @@ export async function EcosystemLayoutSlug({ redirect(ecosystemLayoutPath); } - const ecosystem = await fetchEcosystem( - params.slug, - authToken, - params.team_slug, - ); + const ecosystem = await fetchEcosystem(params.slug, params.team_slug); // Fetch team details to obtain team ID for further authenticated updates const team = await getTeamBySlug(params.team_slug); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx index a09416faeee..a5451019399 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; -/* eslint-disable */ +import type { Ecosystem } from "@/api/ecosystems"; import { Img } from "@/components/blocks/Img"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -26,7 +26,6 @@ import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; -import type { Ecosystem } from "../../../types"; import { useUpdateEcosystem } from "../configuration/hooks/use-update-ecosystem"; import { useEcosystem } from "../hooks/use-ecosystem"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx index 1ddf351c36b..332cf2ea199 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx @@ -1,10 +1,10 @@ import { notFound } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { fetchEcosystem } from "@/api/ecosystems"; import { getTeamBySlug } from "@/api/team"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { getAuthToken } from "../../../../../../../../../../../@/api/auth-token"; import { loginRedirect } from "../../../../../../../../../login/loginRedirect"; import { AddPartnerForm } from "../components/client/add-partner-form.client"; -import { fetchEcosystem } from "../hooks/fetchEcosystem"; export default async function AddPartnerPage({ params, @@ -34,11 +34,10 @@ export default async function AddPartnerPage({ }); try { - const ecosystem = await fetchEcosystem({ - authToken, - slug: ecosystemSlug, - teamIdOrSlug: teamSlug, - }); + const ecosystem = await fetchEcosystem(ecosystemSlug, teamSlug); + if (!ecosystem) { + throw new Error("Ecosystem not found"); + } return (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx index 921de8d3a61..c01d7404a12 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/AddPartnerDialogButton.tsx @@ -2,8 +2,8 @@ import { PlusIcon } from "lucide-react"; import Link from "next/link"; +import type { Ecosystem } from "@/api/ecosystems"; import { Button } from "@/components/ui/button"; -import type { Ecosystem } from "../../../../../types"; export function AddPartnerDialogButton(props: { teamSlug: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx index c839ffb4628..c45a37c02e7 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx @@ -2,8 +2,8 @@ import { useParams } from "next/navigation"; import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import type { Ecosystem, Partner } from "../../../../../types"; import { useAddPartner } from "../../hooks/use-add-partner"; import { PartnerForm, type PartnerFormValues } from "./partner-form.client"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx index 3a0e2fa4d04..83a01b97aa5 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/auth-options-form.client.tsx @@ -12,6 +12,7 @@ import { } from "thirdweb/wallets/smart"; import invariant from "tiny-invariant"; import { z } from "zod"; +import type { AuthOption, Ecosystem } from "@/api/ecosystems"; import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { SettingsCard } from "@/components/blocks/SettingsCard"; import { Button } from "@/components/ui/button"; @@ -36,9 +37,28 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; -import { authOptions, type Ecosystem } from "../../../../../types"; import { useUpdateEcosystem } from "../../hooks/use-update-ecosystem"; +const authOptions = [ + "email", + "phone", + "passkey", + "siwe", + "guest", + "google", + "facebook", + "x", + "discord", + "farcaster", + "telegram", + "github", + "twitch", + "steam", + "apple", + "coinbase", + "line", +] as const satisfies AuthOption[]; + type AuthOptionsFormData = { authOptions: string[]; useCustomAuth: boolean; @@ -113,6 +133,8 @@ export function AuthOptionsForm({ (data) => { if ( data.useSmartAccount && + data.executionMode === "EIP4337" && + data.accountFactoryType === "custom" && data.customAccountFactoryAddress && !isAddress(data.customAccountFactoryAddress) ) { @@ -125,6 +147,23 @@ export function AuthOptionsForm({ path: ["customAccountFactoryAddress"], }, ) + .refine( + (data) => { + if ( + data.useSmartAccount && + data.executionMode === "EIP4337" && + data.accountFactoryType === "custom" && + !data.customAccountFactoryAddress + ) { + return false; + } + return true; + }, + { + message: "Please enter a custom account factory address", + path: ["customAccountFactoryAddress"], + }, + ) .refine( (data) => { if (data.useSmartAccount && (data.defaultChainId ?? 0) <= 0) { @@ -193,21 +232,23 @@ export function AuthOptionsForm({ let smartAccountOptions: Ecosystem["smartAccountOptions"] | null = null; if (data.useSmartAccount) { - let accountFactoryAddress: string; - switch (data.accountFactoryType) { - case "v0.6": - accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_6; - break; - case "v0.7": - accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_7; - break; - case "custom": - if (!data.customAccountFactoryAddress) { - toast.error("Please enter a custom account factory address"); - return; - } - accountFactoryAddress = data.customAccountFactoryAddress; - break; + let accountFactoryAddress: string | undefined; + if (data.executionMode === "EIP4337") { + switch (data.accountFactoryType) { + case "v0.6": + accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_6; + break; + case "v0.7": + accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_7; + break; + case "custom": + if (!data.customAccountFactoryAddress) { + toast.error("Please enter a custom account factory address"); + return; + } + accountFactoryAddress = data.customAccountFactoryAddress; + break; + } } smartAccountOptions = { @@ -220,7 +261,7 @@ export function AuthOptionsForm({ updateEcosystem({ ...ecosystem, - authOptions: data.authOptions as (typeof authOptions)[number][], + authOptions: data.authOptions as AuthOption[], customAuthOptions, smartAccountOptions, }); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx index 4a626708e09..6273dcc84b9 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/integration-permissions-toggle.client.tsx @@ -2,11 +2,11 @@ import { useState } from "react"; import { toast } from "sonner"; import invariant from "tiny-invariant"; +import type { Ecosystem } from "@/api/ecosystems"; import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog"; import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; -import type { Ecosystem } from "../../../../../types"; import { useUpdateEcosystem } from "../../hooks/use-update-ecosystem"; export function IntegrationPermissionsToggle({ diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx index 23e8a914761..87727417fa2 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx @@ -6,6 +6,7 @@ import { useId } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import type { ThirdwebClient } from "thirdweb"; import type { z } from "zod"; +import type { Partner } from "@/api/ecosystems"; import { Button } from "@/components/ui/button"; import { Form, @@ -21,7 +22,6 @@ import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; -import type { Partner } from "../../../../../types"; import { partnerFormSchema } from "../../constants"; import { AllowedOperationsSection } from "./allowed-operations-section"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx index f49a92b5f0b..8c72fb5b111 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx @@ -2,8 +2,8 @@ import { useParams } from "next/navigation"; import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import type { Ecosystem, Partner } from "../../../../../types"; import { useUpdatePartner } from "../../hooks/use-update-partner"; import { PartnerForm, type PartnerFormValues } from "./partner-form.client"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx index 09ebd3196ec..81e6233265f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/auth-options-section.tsx @@ -1,5 +1,5 @@ import type { ThirdwebClient } from "thirdweb"; -import type { Ecosystem } from "../../../../../types"; +import type { Ecosystem } from "@/api/ecosystems"; import { AuthOptionsForm, AuthOptionsFormSkeleton, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx index 6407febcb5e..462149db9cf 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/ecosystem-partners-section.tsx @@ -1,4 +1,4 @@ -import type { Ecosystem } from "../../../../../types"; +import type { Ecosystem } from "@/api/ecosystems"; import { AddPartnerDialogButton } from "../client/AddPartnerDialogButton"; import { PartnersTable } from "./partners-table"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx index adf12a72f89..d359c1879f4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/integration-permissions-section.tsx @@ -1,4 +1,4 @@ -import type { Ecosystem } from "../../../../../types"; +import type { Ecosystem } from "@/api/ecosystems"; import { IntegrationPermissionsToggle, IntegrationPermissionsToggleSkeleton, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx index e65ac9ac787..d95866aa142 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/server/partners-table.tsx @@ -1,6 +1,7 @@ import { Link } from "chakra/link"; import { PencilIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; import { Button } from "@/components/ui/button"; import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog"; import { CopyButton } from "@/components/ui/CopyButton"; @@ -17,7 +18,6 @@ import { import { ToolTipLabel } from "@/components/ui/tooltip"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; -import type { Ecosystem, Partner } from "../../../../../types"; import { usePartners } from "../../../hooks/use-partners"; import { useDeletePartner } from "../../hooks/use-delete-partner"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchEcosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchEcosystem.ts deleted file mode 100644 index a01b9c5590f..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchEcosystem.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; -import type { Ecosystem } from "../../../../types"; - -/** - * Fetches ecosystem data from the server - */ -export async function fetchEcosystem(args: { - teamIdOrSlug: string; - slug: string; - authToken: string; -}): Promise { - const { teamIdOrSlug, slug, authToken } = args; - const res = await fetch( - `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - next: { - revalidate: 0, - }, - }, - ); - - if (!res.ok) { - const data = await res.json(); - console.error(data); - throw new Error( - data?.message ?? data?.error?.message ?? "Failed to fetch ecosystem", - ); - } - - return (await res.json()).result as Ecosystem; -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts index aa91df3eadb..44d10822864 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartnerDetails.ts @@ -1,4 +1,4 @@ -import type { Ecosystem, Partner } from "../../../../types"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; export async function fetchPartnerDetails(args: { authToken: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts index cbdbd94ac50..9784581e2bb 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/fetchPartners.ts @@ -1,4 +1,4 @@ -import type { Ecosystem, Partner } from "../../../../types"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; /** * Fetches partners for an ecosystem diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts index 27bc0e0d739..ee308fc3dcb 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; -import type { Ecosystem, Partner } from "../../../../types"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; type AddPartnerParams = { ecosystem: Ecosystem; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts index 019934a7c11..e97ba222931 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-delete-partner.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; -import type { Ecosystem } from "../../../../types"; +import type { Ecosystem } from "@/api/ecosystems"; type DeletePartnerParams = { ecosystem: Ecosystem; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts index 2878eb42d6b..186f6d513e3 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-ecosystem.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; -import type { Ecosystem } from "../../../../types"; +import type { Ecosystem } from "@/api/ecosystems"; export function useUpdateEcosystem( params: { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts index 1e78500af1f..2973213dafe 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; -import type { Ecosystem, Partner } from "../../../../types"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; type UpdatePartnerParams = { partnerId: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx index 27d1ca9cb74..5d95622522d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx @@ -1,10 +1,10 @@ import { notFound } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { fetchEcosystem } from "@/api/ecosystems"; import { getTeamBySlug } from "@/api/team"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { getAuthToken } from "../../../../../../../../../../../../../@/api/auth-token"; import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect"; import { UpdatePartnerForm } from "../../../components/client/update-partner-form.client"; -import { fetchEcosystem } from "../../../hooks/fetchEcosystem"; import { fetchPartnerDetails } from "../../../hooks/fetchPartnerDetails"; export default async function EditPartnerPage({ @@ -36,11 +36,11 @@ export default async function EditPartnerPage({ }); try { - const ecosystem = await fetchEcosystem({ - authToken, - slug: ecosystemSlug, - teamIdOrSlug: teamSlug, - }); + const ecosystem = await fetchEcosystem(ecosystemSlug, teamSlug); + + if (!ecosystem) { + throw new Error("Ecosystem not found"); + } try { const partner = await fetchPartnerDetails({ diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts index f4f21bc3aa1..a53eec37ecc 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-ecosystem.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { apiServerProxy } from "@/actions/proxies"; -import type { Ecosystem } from "../../../types"; +import type { Ecosystem } from "@/api/ecosystems"; export function useEcosystem({ teamIdOrSlug, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts index d75b9721062..723d69b6c6e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import type { Ecosystem, Partner } from "../../../types"; +import type { Ecosystem, Partner } from "@/api/ecosystems"; import { fetchPartners } from "../configuration/hooks/fetchPartners"; export function usePartners({ diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts index 43b4609cd86..f9b24629c5e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts @@ -2,10 +2,10 @@ import "server-only"; import { redirect } from "next/navigation"; import { upload } from "thirdweb/storage"; +import { getAuthToken } from "@/api/auth-token"; import { BASE_URL } from "@/constants/env-utils"; import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { getAuthToken } from "../../../../../../../../../@/api/auth-token"; export async function createEcosystem(options: { teamSlug: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx index a3858f9555d..e7b65f66286 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; +import { fetchEcosystemList } from "@/api/ecosystems"; import { loginRedirect } from "../../../../../login/loginRedirect"; -import { fetchEcosystemList } from "./utils/fetchEcosystemList"; export default async function Page(props: { params: Promise<{ team_slug: string }>; @@ -15,12 +15,10 @@ export default async function Page(props: { loginRedirect(ecosystemLayoutPath); } - const ecosystems = await fetchEcosystemList(authToken, team_slug).catch( - (err) => { - console.error("failed to fetch ecosystems", err); - return []; - }, - ); + const ecosystems = await fetchEcosystemList(team_slug).catch((err) => { + console.error("failed to fetch ecosystems", err); + return []; + }); if (ecosystems[0]) { redirect(`${ecosystemLayoutPath}/${ecosystems[0].slug}`); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystem.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystem.ts deleted file mode 100644 index fc20152d2a8..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystem.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; -import type { Ecosystem } from "../types"; - -export async function fetchEcosystem( - slug: string, - authToken: string, - teamIdOrSlug: string, -) { - const res = await fetch( - `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - }, - ); - if (!res.ok) { - const data = await res.json(); - console.error(data); - return null; - } - - const data = (await res.json()) as { result: Ecosystem }; - return data.result; -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystemList.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystemList.ts deleted file mode 100644 index 94617169ff1..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/utils/fetchEcosystemList.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; -import type { Ecosystem } from "../types"; - -export async function fetchEcosystemList( - authToken: string, - teamIdOrSlug: string, -) { - const res = await fetch( - `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - }, - ); - - if (!res.ok) { - const data = await res.json(); - console.error(data); - throw new Error(data?.error?.message ?? "Failed to fetch ecosystems"); - } - - const data = (await res.json()) as { result: Ecosystem[] }; - return data.result; -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/_components/service-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/_components/service-card.tsx new file mode 100644 index 00000000000..fb85f10e96d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/_components/service-card.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { EmptyChartState } from "@/components/analytics/empty-chart-state"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; + +type ServiceStatus = "active" | "pending" | "inactive"; + +type InfraServiceCardProps = { + title: string; + status: ServiceStatus; +}; + +export function InfraServiceCard({ title, status }: InfraServiceCardProps) { + return ( +
+ {/* Header row with status and optional action */} +
+
+

{title}

+ + {status === "active" + ? "Active" + : status === "pending" + ? "Pending" + : "Inactive"} + {status === "pending" && } + +
+
+ + +
+ ); +} + +// --- Helper Components --- + +function MetricPlaceholders({ + status, + serviceTitle, +}: { + status: ServiceStatus; + serviceTitle: string; +}) { + const metrics = getMetricsForService(serviceTitle); + + return ( +
+ {metrics.map((metric) => ( + + + {metric.label} + +
+ + {status === "active" ? ( + Coming Soon + ) : status === "pending" ? ( +

Activation in progress.

+ ) : ( +

Activate service to view metrics.

+ )} +
+
+
+ ))} +
+ ); +} + +type Metric = { key: string; label: string }; + +function getMetricsForService(title: string): Metric[] { + const normalized = title.toLowerCase(); + + if (normalized === "rpc") { + return [ + { key: "requests", label: "Requests" }, + { key: "monthly_active_developers", label: "Monthly Active Developers" }, + ]; + } + + if (normalized === "insight") { + return [ + { key: "requests", label: "Requests" }, + { key: "monthly_active_developers", label: "Monthly Active Developers" }, + ]; + } + + if (normalized === "account abstraction") { + return [ + { key: "transactions", label: "Transactions" }, + { key: "monthly_active_developers", label: "Monthly Active Developers" }, + { key: "gas_sponsored", label: "Gas Sponsored" }, + ]; + } + + // fallback empty + return []; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/page.tsx new file mode 100644 index 00000000000..9aa249e8a35 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/[chain_id]/page.tsx @@ -0,0 +1,178 @@ +import { InfoIcon } from "lucide-react"; +import { notFound, redirect } from "next/navigation"; +import { getChainSubscriptionForChain } from "@/api/team-subscription"; +import { formatToDollars } from "@/components/billing/formatToDollars"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { ToolTipLabel } from "../../../../../../../../@/components/ui/tooltip"; +import { getChain } from "../../../../../../(dashboard)/(chain)/utils"; +import { InfraServiceCard } from "./_components/service-card"; + +const PRODUCTS = [ + { + sku: "chain:infra:rpc", + title: "RPC", + }, + { + sku: "chain:infra:insight", + title: "Insight", + }, + { + sku: "chain:infra:account_abstraction", + title: "Account Abstraction", + }, +] as const; + +export default async function DeployInfrastructureOnChainPage(props: { + params: Promise<{ chain_id: string; team_slug: string }>; +}) { + const params = await props.params; + const chain = await getChain(params.chain_id); + + if (!chain) { + notFound(); + } + if (chain.slug !== params.chain_id) { + // redirect to the slug version of the page + redirect(`/team/${params.team_slug}/~/infrastructure/${chain.slug}`); + } + + const chainSubscription = await getChainSubscriptionForChain( + params.team_slug, + chain.chainId, + ); + + if (!chainSubscription) { + notFound(); + } + + const client = getClientThirdwebClient(); + + // Format renewal date and amount due for the subscription summary section + const renewalDate = new Date(chainSubscription.currentPeriodEnd); + const formattedRenewalDate = renewalDate.toLocaleDateString(undefined, { + day: "numeric", + month: "long", + year: "numeric", + }); + + // upcomingInvoice.amount is stored in cents – format to dollars if available + const formattedAmountDue = + chainSubscription.upcomingInvoice.amount !== null + ? formatToDollars(chainSubscription.upcomingInvoice.amount) + : "N/A"; + + return ( +
+ {/* Chain header */} +
+

+ Infrastructure for +

+ + + + {chain.icon && ( + + )} + {cleanChainName(chain.name)} + + + + Chain ID + {chain.chainId} + + + +
+ + {PRODUCTS.map((product) => { + const hasSku = chainSubscription.skus.includes(product.sku); + + // Map sku to chain service key + const skuToServiceKey: Record = { + "chain:infra:account_abstraction": "account-abstraction", + "chain:infra:insight": "insight", + "chain:infra:rpc": "rpc-edge", + }; + + const serviceKey = skuToServiceKey[product.sku]; + const chainService = chain.services.find( + (s) => s.service === serviceKey, + ); + const serviceEnabled = + chainService?.enabled ?? chainService?.status === "enabled"; + + let status: "active" | "pending" | "inactive"; + if (hasSku && serviceEnabled) { + status = "active"; + } else if (hasSku && !serviceEnabled) { + status = "pending"; + } else { + status = "inactive"; + } + + return ( + + ); + })} + + + {/* Subscription summary */} + + + {/* Left: header + info */} +
+
+

Subscription details

+ {chainSubscription.isLegacy && ( + + Enterprise + + This subscription is part of an enterprise agreement and + cannot be modified through the dashboard. Please contact + your account executive for any modifications. + + } + > + + + + )} +
+ +
+
+ Renews on + {formattedRenewalDate} +
+ +
+ Amount due + {formattedAmountDue} +
+
+
+
+
+
+ ); +} + +function cleanChainName(chainName: string) { + return chainName.replace("Mainnet", ""); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/[chain_id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/[chain_id]/page.tsx new file mode 100644 index 00000000000..9960caad2f2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/[chain_id]/page.tsx @@ -0,0 +1,95 @@ +import { ArrowUpDownIcon } from "lucide-react"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { getMembers } from "@/api/team-members"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { getChain } from "../../../../../../../(dashboard)/(chain)/utils"; +import { getValidAccount } from "../../../../../../../account/settings/getAccount"; +import { DeployInfrastructureForm } from "../_components/deploy-infrastructure-form.client"; + +export default async function DeployInfrastructureOnChainPage(props: { + params: Promise<{ chain_id: string; team_slug: string }>; +}) { + const params = await props.params; + + const pagePath = `/team/${params.team_slug}/~/infrastructure/deploy/${params.chain_id}`; + + const [account, chain, members] = await Promise.all([ + getValidAccount(pagePath), + getChain(params.chain_id), + getMembers(params.team_slug), + ]); + + if (!chain) { + notFound(); + } + if (chain.slug !== params.chain_id) { + // redirect to the slug version of the page + redirect(`/team/${params.team_slug}/~/infrastructure/deploy/${chain.slug}`); + } + + if (!members) { + notFound(); + } + + const accountMemberInfo = members.find((m) => m.accountId === account.id); + + if (!accountMemberInfo) { + notFound(); + } + + const client = getClientThirdwebClient(); + + return ( +
+
+

+ Deploy Infrastructure on +

+ + + + {chain.icon && ( + + )} + {cleanChainName(chain.name)} + + + + Chain ID + {chain.chainId} + + + + +
+ +
+ ); +} + +function cleanChainName(chainName: string) { + return chainName.replace("Mainnet", ""); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/_components/deploy-infrastructure-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/_components/deploy-infrastructure-form.client.tsx new file mode 100644 index 00000000000..1fe930bb1f2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/_components/deploy-infrastructure-form.client.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { useQueryState } from "nuqs"; +import { useMemo, useTransition } from "react"; +import { toast } from "sonner"; +import { getChainInfraCheckoutURL } from "@/actions/billing"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { InsightIcon } from "@/icons/InsightIcon"; +import { RPCIcon } from "@/icons/RPCIcon"; +import { SmartAccountIcon } from "@/icons/SmartAccountIcon"; +import { cn } from "@/lib/utils"; +import type { ChainInfraSKU } from "@/types/billing"; +import type { ChainMetadataWithServices } from "@/types/chain"; +import { searchParams } from "../search-params"; + +// Pricing constants (USD) +const SERVICE_CONFIG = { + accountAbstraction: { + annualPrice: 6120, + description: + "Let developers offer gasless transactions and programmable smart accounts out-of-the-box. Powered by ERC-4337 & ERC-7702 for wallet-less onboarding and custom account logic.", + icon: "SmartAccountIcon", + label: "Account Abstraction", + monthlyPrice: 600, + required: false, + sku: "chain:infra:account_abstraction" as const, + }, + insight: { + annualPrice: 15300, + description: + "Arm developers with real-time, indexed data via a turnkey REST API & Webhooks. Query any event, transaction, or token in milliseconds—no subgraph setup or indexer maintenance required.", + icon: "InsightIcon", + label: "Insight", + monthlyPrice: 1500, + required: false, + sku: "chain:infra:insight" as const, + }, + rpc: { + annualPrice: 15300, + description: + "Deliver blazing-fast, reliable RPC endpoints through our global edge network so developers enjoy low-latency reads & writes that seamlessly scale with their traffic.", + icon: "RPCIcon", + label: "RPC", + monthlyPrice: 1500, + required: true, + sku: "chain:infra:rpc" as const, + }, +} satisfies Record< + string, + { + label: string; + description: string; + sku: ChainInfraSKU; + monthlyPrice: number; + annualPrice: number; + required: boolean; + icon: "RPCIcon" | "InsightIcon" | "SmartAccountIcon"; + } +>; + +const formatUSD = (amount: number) => `$${amount.toLocaleString()}`; + +export function DeployInfrastructureForm(props: { + chain: ChainMetadataWithServices; + teamSlug: string; + isOwner: boolean; + className?: string; +}) { + const [isTransitionPending, startTransition] = useTransition(); + + const [frequency, setFrequency] = useQueryState( + "freq", + searchParams.freq.withOptions({ history: "replace", startTransition }), + ); + + const [addonsStr, setAddonsStr] = useQueryState( + "addons", + searchParams.addons.withOptions({ history: "replace", startTransition }), + ); + + const addons = useMemo(() => { + return addonsStr ? addonsStr.split(",").filter(Boolean) : []; + }, [addonsStr]); + + const includeInsight = addons.includes("insight"); + const includeAA = addons.includes("aa"); + + const selectedOrder = useMemo(() => { + const arr: (keyof typeof SERVICE_CONFIG)[] = ["rpc"]; + if (includeInsight) arr.push("insight"); + if (includeAA) arr.push("accountAbstraction"); + return arr; + }, [includeInsight, includeAA]); + + // NEW: count selected services and prepare bundle discount hint + const selectedCount = selectedOrder.length; + + const bundleHint = useMemo(() => { + if (selectedCount === 1) { + return "Add one more add-on to unlock a 10% bundle discount."; + } else if (selectedCount === 2) { + return "Add another add-on to increase your bundle discount to 15%."; + } else if (selectedCount >= 3) { + return "🎉 Congrats! You unlocked the maximum 15% bundle discount."; + } + return null; + }, [selectedCount]); + + const selectedServices = useMemo(() => { + return { + accountAbstraction: includeAA, + insight: includeInsight, + rpc: true, + } as const; + }, [includeInsight, includeAA]); + + const pricePerService = useMemo(() => { + const isAnnual = frequency === "annual"; + const mapping: Record = { + accountAbstraction: + SERVICE_CONFIG.accountAbstraction[ + isAnnual ? "annualPrice" : "monthlyPrice" + ], + insight: + SERVICE_CONFIG.insight[isAnnual ? "annualPrice" : "monthlyPrice"], + rpc: SERVICE_CONFIG.rpc[isAnnual ? "annualPrice" : "monthlyPrice"], + }; + return mapping; + }, [frequency]); + + // Calculate totals and savings correctly + const { subtotal, bundleDiscount, total, totalSavings, originalTotal } = + useMemo(() => { + let subtotal = 0; // price after annual discount but before bundle + let originalTotal = 0; // monthly price * months (12 if annual) with no discounts + let count = 0; + ( + Object.keys(selectedServices) as Array + ).forEach((key) => { + if (selectedServices[key]) { + subtotal += pricePerService[key]; + originalTotal += + SERVICE_CONFIG[key].monthlyPrice * + (frequency === "annual" ? 12 : 1); + count += 1; + } + }); + + let discountRate = 0; + if (count === 2) { + discountRate = 0.1; + } else if (count >= 3) { + discountRate = 0.15; + } + + const annualDiscount = + frequency === "annual" ? originalTotal - subtotal : 0; + const bundleDiscount = subtotal * discountRate; + const total = subtotal - bundleDiscount; + const totalSavings = annualDiscount + bundleDiscount; + return { + annualDiscount, + bundleDiscount, + originalTotal, + subtotal, + total, + totalSavings, + }; + }, [selectedServices, pricePerService, frequency]); + + const chainId = props.chain.chainId; + + const checkout = () => { + startTransition(async () => { + try { + const skus: ChainInfraSKU[] = [SERVICE_CONFIG.rpc.sku]; + if (includeInsight) skus.push(SERVICE_CONFIG.insight.sku); + if (includeAA) skus.push(SERVICE_CONFIG.accountAbstraction.sku); + + const res = await getChainInfraCheckoutURL({ + annual: frequency === "annual", + chainId, + skus, + teamSlug: props.teamSlug, + }); + + // If the action returns, it means redirect did not happen and we have an error + if (res.status === "error") { + toast.error(res.error); + } else if (res.status === "success") { + // replace the current page with the checkout page (which will then redirect back to us) + window.location.href = res.data; + } + } catch (err) { + console.error(err); + toast.error( + "Failed to create checkout session. Please try again later.", + ); + } + }); + }; + + const periodLabel = frequency === "annual" ? "/yr" : "/mo"; + const isAnnual = frequency === "annual"; + + return ( +
+ {/* Left column: service selection + frequency */} +
+

Select Services

+ + {/* Required service */} +
+ {}} + originalPrice={ + isAnnual ? SERVICE_CONFIG.rpc.monthlyPrice * 12 : undefined + } + periodLabel={periodLabel} + price={pricePerService.rpc} + required + selected + /> +
+ + {/* Optional add-ons */} +
+
+

Add-ons

+ {bundleHint && ( +

{bundleHint}

+ )} +
+
+ {/* Insight */} + { + const newVal = !includeInsight; + const newAddons = addons.filter((a) => a !== "insight"); + if (newVal) newAddons.push("insight"); + setAddonsStr(newAddons.join(",")); + }} + originalPrice={ + isAnnual ? SERVICE_CONFIG.insight.monthlyPrice * 12 : undefined + } + periodLabel={periodLabel} + price={pricePerService.insight} + selected={includeInsight} + /> + + {/* Account Abstraction */} + { + const newVal = !includeAA; + const newAddons = addons.filter((a) => a !== "aa"); + if (newVal) newAddons.push("aa"); + setAddonsStr(newAddons.join(",")); + }} + originalPrice={ + isAnnual + ? SERVICE_CONFIG.accountAbstraction.monthlyPrice * 12 + : undefined + } + periodLabel={periodLabel} + price={pricePerService.accountAbstraction} + selected={includeAA} + /> +
+
+
+ + {/* Right column: order summary */} +
+

Order Summary

+
+ {selectedOrder.map((key) => ( +
+ {SERVICE_CONFIG[key].label} + + {isAnnual && ( + + {formatUSD(SERVICE_CONFIG[key].monthlyPrice * 12)} + + )} + + {formatUSD(pricePerService[key])} + {periodLabel} + + +
+ ))} + +
+ Subtotal + + {formatUSD(subtotal)} + {periodLabel} + +
+ {bundleDiscount > 0 && ( +
+ + Bundle Discount ( + {Object.values(selectedServices).filter(Boolean).length === 2 + ? "10%" + : "15%"} + off) + + -{formatUSD(bundleDiscount)} +
+ )} + + {/* Billing Frequency Toggle */} +
+ Pay annually & save 15% + + setFrequency(checked ? "annual" : "monthly") + } + /> +
+ + {/* Total Row */} +
+ Total +

+ {totalSavings > 0 && ( + + {formatUSD(originalTotal)} + + )} + + {formatUSD(total)} {periodLabel} + +

+
+ + + {!props.isOwner && ( +

+ Only team owners can deploy infrastructure. +

+ )} +
+
+
+ ); +} + +// --- Service Card Component --- +type IconKey = "RPCIcon" | "InsightIcon" | "SmartAccountIcon"; + +function getIcon(icon: IconKey) { + switch (icon) { + case "RPCIcon": + return RPCIcon; + case "InsightIcon": + return InsightIcon; + case "SmartAccountIcon": + return SmartAccountIcon; + default: + return RPCIcon; + } +} + +function ServiceCard(props: { + label: string; + description: string; + price: number; + periodLabel: string; + originalPrice?: number; + selected?: boolean; + disabled?: boolean; + required?: boolean; + icon: IconKey; + onToggle: () => void; +}) { + const { + label, + description, + price, + periodLabel, + originalPrice, + selected, + disabled, + required, + icon, + onToggle, + } = props; + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/page.tsx new file mode 100644 index 00000000000..bcc7ddedc71 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +/** + * This page lets customers select a chain to deploy infrastructure on as step one of a 2 step process + * in order to do this customers select a chain from the dropdown and then they can continue to `/team//~/infrastructure/deploy/[chain_id]` + */ + +import { ArrowRightIcon } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; + +export default function DeployInfrastructurePage() { + const client = getClientThirdwebClient(); + + const [chainId, setChainId] = useState(undefined); + + const { team_slug } = useParams<{ team_slug: string }>(); + + return ( +
+
+

+ Deploy Infrastructure +

+
+
+ {/* Header */} +
+

Choose your Chain

+

+ Select the chain you'd like to deploy infrastructure on. In the next + step you'll pick which services you want to enable for all + developers on this chain. +

+
+ + {/* Chain selector */} +
+ + {/* Alternative paths hidden inside popover */} + + + + + +
    +
  1. + Option 1: Submit a PR to  + + ethereum-lists/chains + {" "} + to add your chain.{" "} + + (automatically added on PR merge) + +
  2. +
  3. + Option 2: Share your chain details via  + + this short form + + .
    + + (multiple days for your chain to be included) + +
  4. +
+
+
+
+ +
+ {chainId === undefined ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/search-params.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/search-params.ts new file mode 100644 index 00000000000..02dace3e9e6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/deploy/search-params.ts @@ -0,0 +1,6 @@ +import { parseAsString, parseAsStringEnum } from "nuqs/server"; + +export const searchParams = { + addons: parseAsString.withDefault(""), + freq: parseAsStringEnum(["monthly", "annual"]).withDefault("monthly"), +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/layout.tsx new file mode 100644 index 00000000000..06730684257 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/infrastructure/layout.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; +import { getTeamBySlug } from "@/api/team"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ + team_slug: string; + }>; +}) { + const params = await props.params; + const team = await getTeamBySlug(params.team_slug); + if (!team) { + redirect("/team"); + } + return
{props.children}
; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx index c8608aa95d1..99e99771fd7 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx @@ -85,10 +85,7 @@ export function InviteSection(props: { let bottomSection: React.ReactNode = null; const maxAllowedInvitesAtOnce = 10; // invites are enabled if user has edit permission and team plan is not "free" - const inviteEnabled = - teamPlan !== "free" && - teamPlan !== "starter" && - props.userHasEditPermission; + const inviteEnabled = teamPlan !== "free" && props.userHasEditPermission; const form = useForm({ defaultValues: { @@ -111,7 +108,7 @@ export function InviteSection(props: { }, }); - if (teamPlan === "free" || teamPlan === "starter") { + if (teamPlan === "free") { bottomSection = (

diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx index 5766dc73a04..1904215e5c3 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { TabPathLinks } from "../../../../../../../@/components/ui/tabs"; +import { TabPathLinks } from "@/components/ui/tabs"; export default async function Layout(props: { children: React.ReactNode; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx index 1979044f62b..dd44175614a 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTableUI.tsx @@ -459,7 +459,7 @@ function ProjectFilter(props: { renderOption={(option) => { const project = props.projects.find((p) => p.id === option.value); if (!project) { - return <>; + return null; } return (

diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx index 69c24ba4a42..1b8de7d71f8 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx @@ -7,6 +7,7 @@ import { CoinsIcon, HomeIcon, LockIcon, + RssIcon, SettingsIcon, WalletIcon, } from "lucide-react"; @@ -103,6 +104,11 @@ export function ProjectSidebarLayout(props: { icon: SmartAccountIcon, label: "Account Abstraction", }, + { + href: `${layoutPath}/rpc`, + icon: RssIcon, + label: "RPC", + }, { href: `${layoutPath}/vault`, icon: LockIcon, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx index 9d90c4569ec..0021be1fee5 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { RpcMethodStats } from "@/api/analytics"; import { BadgeContainer } from "@/storybook/utils"; -import type { RpcMethodStats } from "@/types/analytics"; import { RpcMethodBarChartCardUI } from "./RpcMethodBarChartCardUI"; const meta = { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx index 1b51463aaa4..0c43b5e2f08 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx @@ -7,6 +7,7 @@ import { BarChart as RechartsBarChart, XAxis, } from "recharts"; +import type { RpcMethodStats } from "@/api/analytics"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, @@ -14,7 +15,6 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { RpcMethodStats } from "@/types/analytics"; import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard"; export function RpcMethodBarChartCardUI({ diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx index 149dd46e4a4..b56e0bed5c9 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/wallet-credentials/components/credential-type-fields/circle.tsx @@ -17,50 +17,48 @@ export const CircleCredentialFields: React.FC = ({ const entitySecretId = useId(); return ( - <> - + Entity Secret is a 32-byte private key designed to secure your + Developer-Controlled wallets{" "} + + Learn more about entity secret management + + + } + htmlFor={entitySecretId} + isRequired={!isUpdate} + label="Entity Secret" + tooltip={null} + > + - Entity Secret is a 32-byte private key designed to secure your - Developer-Controlled wallets{" "} - - Learn more about entity secret management - - - } - htmlFor={entitySecretId} - isRequired={!isUpdate} - label="Entity Secret" - tooltip={null} - > - (value === "" ? undefined : value), - })} - /> - - + type="password" + {...form.register("entitySecret", { + pattern: { + message: + "Entity secret must be a 32-byte hex string (64 characters)", + value: /^([0-9a-fA-F]{64})?$/, + }, + required: !isUpdate, + setValueAs: (value: string) => (value === "" ? undefined : value), + })} + /> + ); }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/blueprint-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/blueprint-card.tsx deleted file mode 100644 index 79d750da614..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/blueprint-card.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { - Code2Icon, - DatabaseIcon, - ExternalLinkIcon, - ZapIcon, -} from "lucide-react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; - -export function BlueprintCard() { - const features = [ - { - description: "RESTful endpoints for any application", - icon: Code2Icon, - title: "Easy-to-Use API", - }, - { - description: - "No need to index blockchains yourself or manage infrastructure and RPC costs.", - icon: DatabaseIcon, - title: "Managed Infrastructure", - }, - { - description: "Access any transaction, event or token API data", - icon: ZapIcon, - title: "Lightning-Fast Queries", - }, - ]; - - return ( -
- {/* header */} -
-
-

Blueprints

- -
- -
-
-
- - {/* Content */} -
-

- Simple endpoints for querying rich blockchain data -

-

- A blueprint is an API that provides access to on-chain data in a - user-friendly format.
No need for ABIs, decoding, RPC, or web3 - knowledge required to fetch blockchain data. -

- -
- - {/* Features */} -
- {features.map((feature) => ( -
-
- -
-
-

{feature.title}

-

- {feature.description} -

-
-
- ))} -
-
- - {/* Playground link */} -
- -
-
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx new file mode 100644 index 00000000000..2e905a0ca98 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx @@ -0,0 +1,219 @@ +import "server-only"; + +import { ActivityIcon, AlertCircleIcon, CloudAlertIcon } from "lucide-react"; +import { ResponsiveSuspense } from "responsive-rsc"; +import type { ThirdwebClient } from "thirdweb"; +import { + getInsightChainUsage, + getInsightEndpointUsage, + getInsightStatusCodeUsage, + getInsightUsage, +} from "@/api/analytics"; +import type { Range } from "@/components/analytics/date-range-selector"; +import { StatCard } from "@/components/analytics/stat"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { InsightAnalyticsFilter } from "./InsightAnalyticsFilter"; +import { InsightFTUX } from "./insight-ftux"; +import { RequestsByStatusGraph } from "./RequestsByStatusGraph"; +import { TopInsightChainsTable } from "./TopChainsTable"; +import { TopInsightEndpointsTable } from "./TopEndpointsTable"; + +// Error state component for analytics +function AnalyticsErrorState({ + title, + message, + className, +}: { + title: string; + message: string; + className?: string; +}) { + return ( + + +
+ +
+
+

{title}

+

{message}

+
+
+
+ ); +} + +export async function InsightAnalytics(props: { + projectClientId: string; + client: ThirdwebClient; + projectId: string; + teamId: string; + range: Range; + interval: "day" | "week"; +}) { + const { projectId, teamId, range, interval } = props; + + const allTimeRequestsPromise = getInsightUsage({ + from: range.from, + period: "all", + projectId: projectId, + teamId: teamId, + to: range.to, + }); + const chainsDataPromise = getInsightChainUsage({ + from: range.from, + limit: 10, + period: "all", + projectId: projectId, + teamId: teamId, + to: range.to, + }); + const statusCodesDataPromise = getInsightStatusCodeUsage({ + from: range.from, + period: interval, + projectId: projectId, + teamId: teamId, + to: range.to, + }); + const endpointsDataPromise = getInsightEndpointUsage({ + from: range.from, + limit: 10, + period: "all", + projectId: projectId, + teamId: teamId, + to: range.to, + }); + + const [allTimeRequestsData, statusCodesData, endpointsData, chainsData] = + await Promise.all([ + allTimeRequestsPromise, + statusCodesDataPromise, + endpointsDataPromise, + chainsDataPromise, + ]); + + const hasVolume = + "data" in allTimeRequestsData && + allTimeRequestsData.data?.some((d) => d.totalRequests > 0); + + const allTimeRequests = + "data" in allTimeRequestsData + ? allTimeRequestsData.data?.reduce( + (acc, curr) => acc + curr.totalRequests, + 0, + ) + : 0; + + let requestsInPeriod = 0; + let errorsInPeriod = 0; + if ("data" in statusCodesData) { + for (const request of statusCodesData.data) { + requestsInPeriod += request.totalRequests; + if (request.httpStatusCode >= 400) { + errorsInPeriod += request.totalRequests; + } + } + } + const errorRate = Number( + ((errorsInPeriod / (requestsInPeriod || 1)) * 100).toFixed(2), + ); + + if (!hasVolume) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ +
+ + +
+ + +
+ } + searchParamsUsed={["from", "to", "interval"]} + > +
+
+ + `${value}%`} + icon={CloudAlertIcon} + isPending={false} + label="Error rate" + value={errorRate} + /> +
+ + {"errorMessage" in statusCodesData ? ( + + ) : ( + + )} + + +
+ {"errorMessage" in endpointsData ? ( + + ) : ( + + )} +
+ {"errorMessage" in chainsData ? ( + + ) : ( + + )} +
+
+ +
+ ); +} + +function GridWithSeparator(props: { children: React.ReactNode }) { + return ( +
+ {props.children} + {/* Desktop - horizontal middle */} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalyticsFilter.tsx new file mode 100644 index 00000000000..f62e5d43f90 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalyticsFilter.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "@/components/analytics/date-range-selector"; +import { IntervalSelector } from "@/components/analytics/interval-selector"; +import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time"; + +type SearchParams = { + from?: string; + to?: string; + interval?: "day" | "week"; +}; + +export function InsightAnalyticsFilter() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: responsiveSearchParams.from, + interval: responsiveSearchParams.interval, + to: responsiveSearchParams.to, + }); + + return ( +
+ { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + return newParams; + }); + }} + /> + + { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + interval: newInterval, + }; + return newParams; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/RequestsByStatusGraph.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/RequestsByStatusGraph.tsx new file mode 100644 index 00000000000..1418b07043a --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/RequestsByStatusGraph.tsx @@ -0,0 +1,121 @@ +"use client"; +import { format } from "date-fns"; +import { useMemo } from "react"; +import { shortenLargeNumber } from "thirdweb/utils"; +import type { InsightStatusCodeStats } from "@/api/analytics"; +import { EmptyChartState } from "@/components/analytics/empty-chart-state"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import type { ChartConfig } from "@/components/ui/chart"; + +type ChartData = Record & { + time: string; // human readable date +}; +const defaultLabel = 200; + +export function RequestsByStatusGraph(props: { + data: InsightStatusCodeStats[]; + isPending: boolean; + title: string; + description: string; +}) { + const topStatusCodesToShow = 10; + + const { chartConfig, chartData } = useMemo(() => { + const _chartConfig: ChartConfig = {}; + const _chartDataMap: Map = new Map(); + const statusCodeToVolumeMap: Map = new Map(); + // for each stat, add it in _chartDataMap + for (const stat of props.data) { + const chartData = _chartDataMap.get(stat.date); + const { httpStatusCode } = stat; + + // if no data for current day - create new entry + if (!chartData && stat.totalRequests > 0) { + _chartDataMap.set(stat.date, { + time: stat.date, + [httpStatusCode || defaultLabel]: stat.totalRequests, + } as ChartData); + } else if (chartData) { + chartData[httpStatusCode || defaultLabel] = + (chartData[httpStatusCode || defaultLabel] || 0) + stat.totalRequests; + } + + statusCodeToVolumeMap.set( + (httpStatusCode || defaultLabel).toString(), + stat.totalRequests + + (statusCodeToVolumeMap.get( + (httpStatusCode || defaultLabel).toString(), + ) || 0), + ); + } + + const statusCodesSorted = Array.from(statusCodeToVolumeMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map((w) => w[0]); + + const statusCodesToShow = statusCodesSorted.slice(0, topStatusCodesToShow); + const statusCodesAsOther = statusCodesSorted.slice(topStatusCodesToShow); + + // replace chainIdsToTagAsOther chainId with "other" + for (const data of _chartDataMap.values()) { + for (const statusCode in data) { + if (statusCodesAsOther.includes(statusCode)) { + data.others = (data.others || 0) + (data[statusCode] || 0); + delete data[statusCode]; + } + } + } + + statusCodesToShow.forEach((statusCode, i) => { + _chartConfig[statusCode] = { + color: `hsl(var(--chart-${(i % 10) + 1}))`, + label: statusCodesToShow[i], + }; + }); + + if (statusCodesAsOther.length > 0) { + _chartConfig.others = { + color: "hsl(var(--muted-foreground))", + label: "Others", + }; + } + + return { + chartConfig: _chartConfig, + chartData: Array.from(_chartDataMap.values()).sort( + (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), + ), + }; + }, [props.data]); + + return ( + +

+ {props.title} +

+

+ {props.description} +

+
+ } + data={chartData} + emptyChartState={} + hideLabel={false} + isPending={props.isPending} + showLegend + toolTipLabelFormatter={(_v, item) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return format(new Date(time), "MMM d, yyyy"); + } + return undefined; + }} + toolTipValueFormatter={(v) => shortenLargeNumber(v as number)} + variant="stacked" + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopChainsTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopChainsTable.tsx new file mode 100644 index 00000000000..79c661299ae --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopChainsTable.tsx @@ -0,0 +1,106 @@ +"use client"; +import { useMemo } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { shortenLargeNumber } from "thirdweb/utils"; +import type { InsightChainStats } from "@/api/analytics"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CardHeading } from "../../universal-bridge/components/common"; + +export function TopInsightChainsTable(props: { + data: InsightChainStats[]; + client: ThirdwebClient; +}) { + const tableData = useMemo(() => { + return props.data.sort((a, b) => b.totalRequests - a.totalRequests); + }, [props.data]); + const isEmpty = useMemo(() => tableData.length === 0, [tableData]); + + return ( +
+ {/* header */} +
+ Top Chains +
+ +
+ +
+ + + Chain ID + Requests + + + + {tableData.map((chain, i) => { + return ( + + ); + })} + +
+ {isEmpty && ( +
+ No data available +
+ )} +
+
+ ); +} + +function ChainTableRow(props: { + chain?: { + chainId: string; + totalRequests: number; + }; + client: ThirdwebClient; + rowIndex: number; +}) { + const delayAnim = { + animationDelay: `${props.rowIndex * 100}ms`, + }; + + return ( + + + ( +

+ {v === "0" ? "Multichain" : v} +

+ )} + skeletonData="..." + style={delayAnim} + /> +
+ + { + return

{shortenLargeNumber(v)}

; + }} + skeletonData={0} + style={delayAnim} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopEndpointsTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopEndpointsTable.tsx new file mode 100644 index 00000000000..1ee0853f08b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/TopEndpointsTable.tsx @@ -0,0 +1,106 @@ +"use client"; +import { useMemo } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { shortenLargeNumber } from "thirdweb/utils"; +import type { InsightEndpointStats } from "@/api/analytics"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CardHeading } from "../../universal-bridge/components/common"; + +export function TopInsightEndpointsTable(props: { + data: InsightEndpointStats[]; + client: ThirdwebClient; +}) { + const tableData = useMemo(() => { + return props.data?.sort((a, b) => b.totalRequests - a.totalRequests); + }, [props.data]); + const isEmpty = useMemo(() => tableData.length === 0, [tableData]); + + return ( +
+ {/* header */} +
+ Top Endpoints +
+ +
+ + + + + Endpoint + Requests + + + + {tableData.map((endpoint, i) => { + return ( + + ); + })} + +
+ {isEmpty && ( +
+ No data available +
+ )} +
+
+ ); +} + +function EndpointTableRow(props: { + endpoint?: { + endpoint: string; + totalRequests: number; + }; + client: ThirdwebClient; + rowIndex: number; +}) { + const delayAnim = { + animationDelay: `${props.rowIndex * 100}ms`, + }; + + return ( + + + ( +

+ {v} +

+ )} + skeletonData="..." + style={delayAnim} + /> +
+ + { + return

{shortenLargeNumber(v)}

; + }} + skeletonData={0} + style={delayAnim} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/insight-ftux.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/insight-ftux.tsx similarity index 91% rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/insight-ftux.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/insight-ftux.tsx index 12fc1cd34ce..22caabe269b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/insight-ftux.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/insight-ftux.tsx @@ -1,7 +1,7 @@ import { CodeServer } from "@/components/ui/code/code.server"; import { isProd } from "@/constants/env-utils"; -import { ClientIDSection } from "../components/ProjectFTUX/ClientIDSection"; -import { WaitingForIntegrationCard } from "../components/WaitingForIntegrationCard/WaitingForIntegrationCard"; +import { ClientIDSection } from "../../components/ProjectFTUX/ClientIDSection"; +import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard"; export function InsightFTUX(props: { clientId: string }) { return ( diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/layout.tsx new file mode 100644 index 00000000000..6886c4b5bab --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/layout.tsx @@ -0,0 +1,97 @@ +import { redirect } from "next/navigation"; +import { getProject } from "@/api/projects"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { FooterLinksSection } from "../components/footer/FooterLinksSection"; + +export default async function Layout(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + }>; + children: React.ReactNode; +}) { + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + return ( +
+
+
+

+ Insight +

+

+ APIs to retrieve blockchain data from any EVM chain, enrich it with + metadata, and transform it using custom logic.{" "} + + Learn more + +

+
+
+ +
+
+ {props.children} +
+ +
+
+
+ +
+
+
+ ); +} + +function InsightFooter() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx index 3c55930a3d0..3e326952fa3 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/page.tsx @@ -1,109 +1,83 @@ -import { notFound } from "next/navigation"; -import { isProjectActive } from "@/api/analytics"; +import { loginRedirect } from "@app/login/loginRedirect"; +import { ArrowUpRightIcon } from "lucide-react"; +import { redirect } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/projects"; -import { getTeamBySlug } from "@/api/team"; -import { FooterLinksSection } from "../components/footer/FooterLinksSection"; -import { BlueprintCard } from "./blueprint-card"; -import { InsightFTUX } from "./insight-ftux"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { getFiltersFromSearchParams } from "@/lib/time"; +import { InsightAnalytics } from "./components/InsightAnalytics"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string; }>; + searchParams: Promise<{ + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; + }>; }) { - const params = await props.params; + const [params, authToken] = await Promise.all([props.params, getAuthToken()]); + + const project = await getProject(params.team_slug, params.project_slug); - const [team, project] = await Promise.all([ - getTeamBySlug(params.team_slug), - getProject(params.team_slug, params.project_slug), - ]); + if (!authToken) { + loginRedirect(`/team/${params.team_slug}/${params.project_slug}/insight`); + } - if (!team || !project) { - notFound(); + if (!project) { + redirect(`/team/${params.team_slug}`); } - const activeResponse = await isProjectActive({ - projectId: project.id, - teamId: team.id, + const searchParams = await props.searchParams; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, }); - const showFTUX = !activeResponse.insight; + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); return ( -
- {/* header */} -
-
-

- Insight -

-

- APIs to retrieve blockchain data from any EVM chain, enrich it with - metadata, and transform it using custom logic -

-
-
- -
+ +
+ -
- {showFTUX ? ( - - ) : ( - - )} -
- -
-
-
- +
+
+
+
+
+

Get Started with Insight

+

+ A cross-chain API for historic blockchain data. +

+
+ + Learn More + + +
-
- ); -} - -function InsightFooter() { - return ( - + ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx new file mode 100644 index 00000000000..a49f84b2fcd --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx @@ -0,0 +1,132 @@ +"use client"; +import { useMemo, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { shortenLargeNumber } from "thirdweb/utils"; +import type { RpcMethodStats } from "@/api/analytics"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { Card } from "@/components/ui/card"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CardHeading } from "../../universal-bridge/components/common"; + +export function TopRPCMethodsTable(props: { + data: RpcMethodStats[]; + client: ThirdwebClient; +}) { + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 30; + + const sortedData = useMemo(() => { + return props.data?.sort((a, b) => b.count - a.count) || []; + }, [props.data]); + + const totalPages = useMemo(() => { + return Math.ceil(sortedData.length / itemsPerPage); + }, [sortedData.length]); + + const tableData = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return sortedData.slice(startIndex, endIndex); + }, [sortedData, currentPage]); + + const isEmpty = useMemo(() => sortedData.length === 0, [sortedData]); + + return ( + + {/* header */} +
+ Top EVM Methods Called +
+ +
+ + + + + Method + Requests + + + + {tableData.map((method, i) => { + return ( + + ); + })} + +
+ {isEmpty && ( +
+ No data available +
+ )} +
+ + {!isEmpty && totalPages > 1 && ( +
+ +
+ )} + + ); +} + +function MethodTableRow(props: { + method?: { + evmMethod: string; + count: number; + }; + client: ThirdwebClient; + rowIndex: number; +}) { + const delayAnim = { + animationDelay: `${props.rowIndex * 100}ms`, + }; + + return ( + + + ( +

+ {v} +

+ )} + skeletonData="..." + style={delayAnim} + /> +
+ + { + return

{shortenLargeNumber(v)}

; + }} + skeletonData={0} + style={delayAnim} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx new file mode 100644 index 00000000000..c1964d2c2f9 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { format } from "date-fns"; +import { shortenLargeNumber } from "thirdweb/utils"; +import type { RpcUsageTypeStats } from "@/api/analytics"; +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; + +export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) { + return ( + new Date(a.date).getTime() - new Date(b.date).getTime()) + .reduce( + (acc, curr) => { + const existingEntry = acc.find((e) => e.time === curr.date); + if (existingEntry) { + existingEntry.requests += curr.count; + } else { + acc.push({ + requests: curr.count, + time: curr.date, + }); + } + return acc; + }, + [] as { requests: number; time: string }[], + )} + header={{ + description: "Requests over time.", + title: "RPC Requests", + }} + hideLabel={false} + isPending={false} + showLegend + toolTipLabelFormatter={(label) => { + return format(label, "MMM dd, HH:mm"); + }} + toolTipValueFormatter={(value) => { + return shortenLargeNumber(value as number); + }} + xAxis={{ + sameDay: true, + }} + yAxis + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx new file mode 100644 index 00000000000..a3854c546f5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx @@ -0,0 +1,98 @@ +import { ActivityIcon } from "lucide-react"; +import { ResponsiveSuspense } from "responsive-rsc"; +import type { ThirdwebClient } from "thirdweb"; +import { getRpcMethodUsage, getRpcUsageByType } from "@/api/analytics"; +import type { Range } from "@/components/analytics/date-range-selector"; +import { StatCard } from "@/components/analytics/stat"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TopRPCMethodsTable } from "./MethodsTable"; +import { RequestsGraph } from "./RequestsGraph"; +import { RpcAnalyticsFilter } from "./RpcAnalyticsFilter"; +import { RpcFTUX } from "./RpcFtux"; + +export async function RPCAnalytics(props: { + projectClientId: string; + client: ThirdwebClient; + projectId: string; + teamId: string; + range: Range; + interval: "day" | "week"; +}) { + const { projectId, teamId, range, interval } = props; + + // TODO: add requests by status code, but currently not performant enough + const allRequestsByUsageTypePromise = getRpcUsageByType({ + from: range.from, + period: "all", + projectId: projectId, + teamId: teamId, + to: range.to, + }); + const requestsByUsageTypePromise = getRpcUsageByType({ + from: range.from, + period: interval, + projectId: projectId, + teamId: teamId, + to: range.to, + }); + const evmMethodsPromise = getRpcMethodUsage({ + from: range.from, + period: "all", + projectId: projectId, + teamId: teamId, + to: range.to, + }).catch((error) => { + console.error(error); + return []; + }); + + const [allUsageData, usageData, evmMethodsData] = await Promise.all([ + allRequestsByUsageTypePromise, + requestsByUsageTypePromise, + evmMethodsPromise, + ]); + + const totalRequests = allUsageData.reduce((acc, curr) => acc + curr.count, 0); + + if (totalRequests < 1) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ + + + +
+ } + searchParamsUsed={["from", "to", "interval"]} + > +
+
+ +
+ + +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx new file mode 100644 index 00000000000..2ebf01ba6c6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "@/components/analytics/date-range-selector"; +import { IntervalSelector } from "@/components/analytics/interval-selector"; +import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time"; + +type SearchParams = { + from?: string; + to?: string; + interval?: "day" | "week"; +}; + +export function RpcAnalyticsFilter() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: responsiveSearchParams.from, + interval: responsiveSearchParams.interval, + to: responsiveSearchParams.to, + }); + + return ( +
+ { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + return newParams; + }); + }} + /> + + { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + interval: newInterval, + }; + return newParams; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx new file mode 100644 index 00000000000..90d87f6bba7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx @@ -0,0 +1,104 @@ +import { CodeServer } from "@/components/ui/code/code.server"; +import { isProd } from "@/constants/env-utils"; +import { ClientIDSection } from "../../components/ProjectFTUX/ClientIDSection"; +import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard"; + +export function RpcFTUX(props: { clientId: string }) { + return ( + + ), + label: "JavaScript", + }, + { + code: ( + + ), + label: "Python", + }, + { + code: ( + + ), + label: "Curl", + }, + ]} + ctas={[ + { + href: "https://portal.thirdweb.com/rpc-edge", + label: "View Docs", + }, + ]} + title="Start Using RPC" + > + +
+ + ); +} + +const twDomain = isProd ? "thirdweb" : "thirdweb-dev"; + +const jsCode = (clientId: string) => `\ +// Example: Get latest block number on Ethereum +const res = await fetch("https://1.rpc.${twDomain}.com/${clientId}", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_blockNumber", + params: [], + id: 1, + }), +}); +const data = await res.json(); +console.log("Latest block number:", parseInt(data.result, 16)); +`; + +const curlCode = (clientId: string) => `\ +# Example: Get latest block number on Ethereum +curl -X POST "https://1.rpc.${twDomain}.com/${clientId}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1 + }' +`; + +const pythonCode = (clientId: string) => `\ +# Example: Get latest block number on Ethereum +import requests +import json + +response = requests.post( + "https://1.rpc.${twDomain}.com/${clientId}", + headers={"Content-Type": "application/json"}, + json={ + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1 + } +) +data = response.json() +print("Latest block number:", int(data["result"], 16)) +`; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx new file mode 100644 index 00000000000..1fbfa67c9f2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx @@ -0,0 +1,46 @@ +import { redirect } from "next/navigation"; +import { getProject } from "@/api/projects"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; + +export default async function Layout(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + }>; + children: React.ReactNode; +}) { + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + return ( +
+
+
+

+ RPC +

+

+ Remote Procedure Call (RPC) provides reliable access to querying + data and interacting with the blockchain through global edge RPCs.{" "} + + Learn more + +

+
+
+ +
+
+ {props.children} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx new file mode 100644 index 00000000000..6730b6c1d22 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx @@ -0,0 +1,61 @@ +import { loginRedirect } from "@app/login/loginRedirect"; +import { redirect } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/projects"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { getFiltersFromSearchParams } from "@/lib/time"; +import { RPCAnalytics } from "./components/RpcAnalytics"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + }>; + searchParams: Promise<{ + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; + }>; +}) { + const [params, authToken] = await Promise.all([props.params, getAuthToken()]); + + const project = await getProject(params.team_slug, params.project_slug); + + if (!authToken) { + loginRedirect(`/team/${params.team_slug}/${params.project_slug}/rpc`); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const searchParams = await props.searchParams; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + return ( + +
+ +
+
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx new file mode 100644 index 00000000000..bae8e63417d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { ArrowRightIcon, RefreshCcwIcon } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { apiServerProxy } from "@/actions/proxies"; +import { reportUpsellClicked, reportUpsellShown } from "@/analytics/report"; +import type { Team } from "@/api/team"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useStripeRedirectEvent } from "@/hooks/stripe/redirect-event"; +import { pollWithTimeout } from "@/utils/pollWithTimeout"; +import { tryCatch } from "@/utils/try-catch"; + +export function StorageErrorPlanUpsell(props: { + teamSlug: string; + trackingCampaign: "create-coin" | "create-nft"; + onRetry: () => void; +}) { + const [isPlanUpdated, setIsPlanUpdated] = useState(false); + const [isPollingTeam, setIsPollingTeam] = useState(false); + + useStripeRedirectEvent(async () => { + setIsPollingTeam(true); + await tryCatch( + pollWithTimeout({ + shouldStop: async () => { + const team = await getTeam(props.teamSlug); + if (team.billingPlan !== "free") { + setIsPlanUpdated(true); + return true; + } + return false; + }, + timeoutMs: 10000, + }), + ); + + setIsPollingTeam(false); + }); + + const isEventSent = useRef(false); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (isEventSent.current) { + return; + } + + isEventSent.current = true; + reportUpsellShown({ + campaign: props.trackingCampaign, + content: "storage-limit", + sku: "plan:starter", + }); + }, [props.trackingCampaign]); + + return ( +
+ {isPlanUpdated ? ( +
+

Plan upgraded successfully

+
+ +
+
+ ) : ( +
+

+ You have reached the storage limit on the free plan +

+

+ Upgrade now to unlock unlimited storage with any paid plan +

+ +
+ + + + + + + +
+
+ )} +
+ ); +} + +async function getTeam(teamSlug: string) { + const res = await apiServerProxy<{ + result: Team; + }>({ + method: "GET", + pathname: `/v1/teams/${teamSlug}`, + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data.result; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx index a4cf72010b9..c3a40d3c18f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx @@ -10,6 +10,7 @@ import { } from "thirdweb"; import { useActiveAccount } from "thirdweb/react"; import { reportAssetCreationStepConfigured } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { type CreateNFTCollectionFunctions, type NFTCollectionInfoFormValues, @@ -30,6 +31,7 @@ export function CreateNFTPageUI(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const [step, setStep] = useState("collection-info"); @@ -140,6 +142,7 @@ export function CreateNFTPageUI(props: { setStep(nftCreationPages["sales-settings"]); }} projectSlug={props.projectSlug} + teamPlan={props.teamPlan} teamSlug={props.teamSlug} values={{ collectionInfo: nftCollectionInfoForm.watch(), diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx index ece2365437d..7db1b21e18b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx @@ -25,6 +25,7 @@ import { reportAssetCreationFailed, reportContractDeployed, } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { useAddContractToProject } from "@/hooks/project-contracts"; import { parseError } from "@/utils/errorParser"; import type { CreateNFTCollectionAllValues } from "./_common/form"; @@ -37,6 +38,7 @@ export function CreateNFTPage(props: { projectSlug: string; teamId: string; projectId: string; + teamPlan: Team["billingPlan"]; }) { const activeAccount = useActiveAccount(); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx index 40acfb50e38..7010f4c0e39 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx @@ -9,7 +9,11 @@ import Link from "next/link"; import { useMemo, useRef, useState } from "react"; import { defineChain, type ThirdwebClient } from "thirdweb"; import { TokenProvider, TokenSymbol, useActiveWallet } from "thirdweb/react"; -import { reportAssetCreationFailed } from "@/analytics/report"; +import { + reportAssetCreationFailed, + reportAssetCreationSuccessful, +} from "@/analytics/report"; +import type { Team } from "@/api/team"; import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status"; import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status"; import { WalletAddress } from "@/components/blocks/wallet-address"; @@ -28,6 +32,7 @@ import { parseError } from "@/utils/errorParser"; import { ChainOverview } from "../../_common/chain-overview"; import { FilePreview } from "../../_common/file-preview"; import { StepCard } from "../../_common/step-card"; +import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell"; import type { CreateNFTCollectionAllValues, CreateNFTCollectionFunctions, @@ -49,6 +54,7 @@ export function LaunchNFT(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const formValues = props.values; const [steps, setSteps] = useState[]>([]); @@ -222,6 +228,11 @@ export function LaunchNFT(props: { } } + reportAssetCreationSuccessful({ + assetType: "nft", + contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155", + }); + props.onLaunchSuccess(); batchesProcessedRef.current = 0; } @@ -304,7 +315,26 @@ export function LaunchNFT(props: { )} - + { + if ( + props.teamPlan === "free" && + errorMessage.toLowerCase().includes("storage limit") + ) { + return ( + handleRetry(step)} + teamSlug={props.teamSlug} + trackingCampaign="create-nft" + /> + ); + } + + return null; + }} + steps={steps} + />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx index d79e1f365ff..bb059cc64bf 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx @@ -54,6 +54,7 @@ export default async function Page(props: { projectId={project.id} projectSlug={params.project_slug} teamId={team.id} + teamPlan={team.billingPlan} teamSlug={params.team_slug} />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx index 9115bcdd083..01ebb6d2bad 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/sales/sales-settings.tsx @@ -2,8 +2,8 @@ import type { UseFormReturn } from "react-hook-form"; import type { ThirdwebClient } from "thirdweb"; import { BasisPointsInput } from "@/components/blocks/BasisPointsInput"; import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { SolidityInput } from "@/components/solidity-inputs"; import { Form } from "@/components/ui/form"; -import { SolidityInput } from "../../../../../../../../../../@/components/solidity-inputs"; import { StepCard } from "../../_common/step-card"; import type { NFTSalesSettingsFormValues } from "../_common/form"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx index 28eee054f9d..40d170391df 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx @@ -23,6 +23,7 @@ import { reportAssetCreationFailed, reportContractDeployed, } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { DEFAULT_FEE_BPS_NEW, DEFAULT_FEE_RECIPIENT, @@ -42,6 +43,7 @@ export function CreateTokenAssetPage(props: { projectId: string; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const account = useActiveAccount(); const { idToChain } = useAllChainsData(); @@ -347,6 +349,7 @@ export function CreateTokenAssetPage(props: { ); }} projectSlug={props.projectSlug} + teamPlan={props.teamPlan} teamSlug={props.teamSlug} /> ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx index 10699b2dfb6..f97b4e06df4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx @@ -9,6 +9,7 @@ import { type ThirdwebClient, } from "thirdweb"; import { reportAssetCreationStepConfigured } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { type CreateAssetFormValues, type TokenDistributionFormValues, @@ -38,6 +39,7 @@ export function CreateTokenAssetPageUI(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const [step, setStep] = useState<"token-info" | "distribution" | "launch">( "token-info", @@ -133,6 +135,7 @@ export function CreateTokenAssetPageUI(props: { setStep("distribution"); }} projectSlug={props.projectSlug} + teamPlan={props.teamPlan} teamSlug={props.teamSlug} values={{ ...tokenInfoForm.getValues(), diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx index 185efd02438..5053343245f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx @@ -45,6 +45,7 @@ export const Default: Story = { createTokenFunctions: mockCreateTokenFunctions, onLaunchSuccess: () => {}, projectSlug: "test-project", + teamPlan: "free", teamSlug: "test-team", }, }; @@ -62,6 +63,27 @@ export const ErrorOnDeploy: Story = { }, onLaunchSuccess: () => {}, projectSlug: "test-project", + teamPlan: "free", + teamSlug: "test-team", + }, +}; + +export const StorageErrorOnDeploy: Story = { + args: { + accountAddress: "0x1234567890123456789012345678901234567890", + client: storybookThirdwebClient, + createTokenFunctions: { + ...mockCreateTokenFunctions, + deployContract: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + throw new Error( + "You have reached your storage limit. Please add a valid payment method to continue using the service.", + ); + }, + }, + onLaunchSuccess: () => {}, + projectSlug: "test-project", + teamPlan: "free", teamSlug: "test-team", }, }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx index ef0ad460c20..fd3aa75cddd 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx @@ -12,6 +12,7 @@ import { reportAssetCreationFailed, reportAssetCreationSuccessful, } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { type MultiStepState, MultiStepStatus, @@ -29,6 +30,7 @@ import { parseError } from "@/utils/errorParser"; import { ChainOverview } from "../../_common/chain-overview"; import { FilePreview } from "../../_common/file-preview"; import { StepCard } from "../../_common/step-card"; +import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell"; import type { CreateAssetFormValues } from "../_common/form"; import type { CreateTokenFunctions } from "../create-token-page.client"; import { TokenDistributionBarChart } from "../distribution/token-distribution"; @@ -50,6 +52,7 @@ export function LaunchTokenStatus(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const formValues = props.values; const { createTokenFunctions } = props; @@ -177,7 +180,6 @@ export function LaunchTokenStatus(props: { await executeSteps(steps, startIndex); } - return ( - + { + if ( + props.teamPlan === "free" && + errorMessage.toLowerCase().includes("storage limit") + ) { + return ( + handleRetry(step)} + teamSlug={props.teamSlug} + trackingCampaign="create-coin" + /> + ); + } + + return null; + }} + steps={steps} + />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx index 2e4b938550e..a6e63df8be1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx @@ -54,6 +54,7 @@ export default async function Page(props: { projectId={project.id} projectSlug={params.project_slug} teamId={team.id} + teamPlan={team.billingPlan} teamSlug={params.team_slug} />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx index 52b1b1bd950..01435e37191 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react"; import type { ThirdwebClient } from "thirdweb"; import type { Project } from "@/api/projects"; import { type Step, StepsCard } from "@/components/blocks/StepsCard"; -import { Button } from "../../../../../../../../@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { CreateVaultAccountButton } from "../../vault/components/create-vault-account.client"; import CreateServerWallet from "../server-wallets/components/create-server-wallet.client"; import type { Wallet } from "../server-wallets/wallet-table/types"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx index a373463c99d..2f7c7aba132 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table-ui.tsx @@ -46,7 +46,10 @@ import type { // TODO - add Status selector dropdown here export function TransactionsTableUI(props: { - getData: (params: { page: number }) => Promise; + getData: (params: { + page: number; + status: TransactionStatus | undefined; + }) => Promise; project: Project; teamSlug: string; wallets?: Wallet[]; @@ -63,8 +66,8 @@ export function TransactionsTableUI(props: { const transactionsQuery = useQuery({ enabled: !!props.wallets && props.wallets.length > 0, placeholderData: keepPreviousData, - queryFn: () => props.getData({ page }), - queryKey: ["transactions", props.project.id, page], + queryFn: () => props.getData({ page, status }), + queryKey: ["transactions", props.project.id, page, status], refetchInterval: autoUpdate ? 4_000 : false, }); @@ -222,10 +225,6 @@ export const statusDetails = { name: "Queued", type: "warning", }, - REVERTED: { - name: "Reverted", - type: "destructive", - }, SUBMITTED: { name: "Submitted", type: "warning", diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx index 3f4dfc3e0b6..4d212e44aa9 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/tx-table.tsx @@ -5,7 +5,7 @@ import { engineCloudProxy } from "@/actions/proxies"; import type { Project } from "@/api/projects"; import type { Wallet } from "../../server-wallets/wallet-table/types"; import { TransactionsTableUI } from "./tx-table-ui"; -import type { TransactionsResponse } from "./types"; +import type { TransactionStatus, TransactionsResponse } from "./types"; export function TransactionsTable(props: { project: Project; @@ -16,10 +16,11 @@ export function TransactionsTable(props: { return ( { + getData={async ({ page, status }) => { return await getTransactions({ page, project: props.project, + status, }); }} project={props.project} @@ -32,23 +33,26 @@ export function TransactionsTable(props: { async function getTransactions({ project, page, + status, }: { project: Project; page: number; + status: TransactionStatus | undefined; }) { const transactions = await engineCloudProxy<{ result: TransactionsResponse }>( { - body: JSON.stringify({ - limit: 20, - page, - }), headers: { "Content-Type": "application/json", "x-client-id": project.publishableKey, "x-team-id": project.teamId, }, - method: "POST", - pathname: "/v1/transactions/search", + method: "GET", + pathname: `/v1/transactions`, + searchParams: { + limit: "20", + page: page.toString(), + status: status ?? undefined, + }, }, ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts index f176f30c35b..7ea643a251f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/tx-table/types.ts @@ -85,12 +85,7 @@ export type Transaction = { cancelledAt: Date | null; }; -export type TransactionStatus = - | "QUEUED" - | "SUBMITTED" - | "CONFIRMED" - | "REVERTED" - | "FAILED"; +export type TransactionStatus = "QUEUED" | "SUBMITTED" | "CONFIRMED" | "FAILED"; type Pagination = { totalCount: number; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts index de83a7a8dcb..2fec73c25d6 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts @@ -122,7 +122,9 @@ export async function getTransactionsChart({ // TODO - need to handle this error state, like we do with the connect charts throw new Error( - `Error fetching transactions chart data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`, + `Error fetching transactions chart data: ${response.status} ${ + response.statusText + } - ${await response.text().catch(() => "Unknown error")}`, ); } @@ -192,7 +194,9 @@ export async function getSingleTransaction({ // TODO - need to handle this error state, like we do with the connect charts throw new Error( - `Error fetching single transaction data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`, + `Error fetching single transaction data: ${response.status} ${ + response.statusText + } - ${await response.text().catch(() => "Unknown error")}`, ); } @@ -200,3 +204,77 @@ export async function getSingleTransaction({ return data.transactions[0]; } + +// Activity log types +export type ActivityLogEntry = { + id: string; + transactionId: string; + batchIndex: number; + eventType: string; + stageName: string; + executorName: string; + notificationId: string; + payload: Record | string | number | boolean | null; + timestamp: string; + createdAt: string; +}; + +type ActivityLogsResponse = { + result: { + activityLogs: ActivityLogEntry[]; + transaction: { + id: string; + batchIndex: number; + clientId: string; + }; + pagination: { + totalCount: number; + page: number; + limit: number; + }; + }; +}; + +export async function getTransactionActivityLogs({ + teamId, + clientId, + transactionId, +}: { + teamId: string; + clientId: string; + transactionId: string; +}): Promise { + const authToken = await getAuthToken(); + + const response = await fetch( + `${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/activity-logs?transactionId=${transactionId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": clientId, + "x-team-id": teamId, + }, + method: "GET", + }, + ); + + if (!response.ok) { + if (response.status === 401) { + return []; + } + + // Don't throw on 404 - activity logs might not exist for all transactions + if (response.status === 404) { + return []; + } + + console.error( + `Error fetching activity logs: ${response.status} ${response.statusText}`, + ); + return []; + } + + const data = (await response.json()) as ActivityLogsResponse; + return data.result.activityLogs; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx index 957e8de6642..f76e1cea6e4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx @@ -1,11 +1,180 @@ import { loginRedirect } from "@app/login/loginRedirect"; +import type { AbiFunction } from "abitype"; import { notFound, redirect } from "next/navigation"; +import { getContract, toTokens } from "thirdweb"; +import { defineChain, getChainMetadata } from "thirdweb/chains"; +import { getCompilerMetadata } from "thirdweb/contract"; +import { + decodeFunctionData, + shortenAddress, + toFunctionSelector, +} from "thirdweb/utils"; import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/projects"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { getSingleTransaction } from "../../lib/analytics"; +import { serverThirdwebClient } from "@/constants/thirdweb-client.server"; +import type { Transaction } from "../../analytics/tx-table/types"; +import { + getSingleTransaction, + getTransactionActivityLogs, +} from "../../lib/analytics"; import { TransactionDetailsUI } from "./transaction-details-ui"; +type AbiItem = + | AbiFunction + | { + type: string; + name?: string; + }; + +export type DecodedTransactionData = { + chainId: number; + contractAddress: string; + value: string; + contractName: string; + functionName: string; + functionArgs: Record; +} | null; + +export type DecodedTransactionResult = DecodedTransactionData[]; + +async function decodeSingleTransactionParam( + txParam: { + to: string; + data: `0x${string}`; + value: string; + }, + chainId: number, +): Promise { + try { + if (!txParam || !txParam.to || !txParam.data) { + return null; + } + + // eslint-disable-next-line no-restricted-syntax + const chain = defineChain(chainId); + + // Create contract instance + const contract = getContract({ + address: txParam.to, + chain, + client: serverThirdwebClient, + }); + + // Fetch compiler metadata + const chainMetadata = await getChainMetadata(chain); + + const txValue = `${txParam.value ? toTokens(BigInt(txParam.value), chainMetadata.nativeCurrency.decimals) : "0"} ${chainMetadata.nativeCurrency.symbol}`; + + if (txParam.data === "0x") { + return { + chainId, + contractAddress: txParam.to, + contractName: shortenAddress(txParam.to), + functionArgs: {}, + functionName: "Transfer", + value: txValue, + }; + } + + const compilerMetadata = await getCompilerMetadata(contract); + + if (!compilerMetadata || !compilerMetadata.abi) { + return null; + } + + const contractName = compilerMetadata.name || "Unknown Contract"; + const abi = compilerMetadata.abi; + + // Extract function selector from transaction data (first 4 bytes) + const functionSelector = txParam.data.slice(0, 10) as `0x${string}`; + + // Find matching function in ABI + const functions = (abi as readonly AbiItem[]).filter( + (item): item is AbiFunction => item.type === "function", + ); + let matchingFunction: AbiFunction | null = null; + + for (const func of functions) { + const selector = toFunctionSelector(func); + if (selector === functionSelector) { + matchingFunction = func; + break; + } + } + + if (!matchingFunction) { + return null; + } + + const functionName = matchingFunction.name; + + // Decode function data + const decodedArgs = (await decodeFunctionData({ + contract: getContract({ + ...contract, + abi: [matchingFunction], + }), + data: txParam.data, + })) as readonly unknown[]; + + // Create a clean object for display + const functionArgs: Record = {}; + if (matchingFunction.inputs && decodedArgs) { + for (let index = 0; index < matchingFunction.inputs.length; index++) { + const input = matchingFunction.inputs[index]; + if (input) { + functionArgs[input.name || `arg${index}`] = decodedArgs[index]; + } + } + } + + return { + chainId, + contractAddress: txParam.to, + contractName, + functionArgs, + functionName, + value: txValue, + }; + } catch (error) { + console.error("Error decoding transaction param:", error); + return null; + } +} + +async function decodeTransactionData( + transaction: Transaction, +): Promise { + try { + // Check if we have transaction parameters + if ( + !transaction.transactionParams || + transaction.transactionParams.length === 0 + ) { + return []; + } + + // Ensure we have a chainId + if (!transaction.chainId) { + return []; + } + + const chainId = parseInt(transaction.chainId); + + // Decode all transaction parameters in parallel + const decodingPromises = transaction.transactionParams.map((txParam) => + decodeSingleTransactionParam(txParam, chainId), + ); + + const results = await Promise.all(decodingPromises); + return results; + } catch (error) { + console.error("Error decoding transaction:", error); + return []; + } +} + export default async function TransactionPage({ params, }: { @@ -26,11 +195,18 @@ export default async function TransactionPage({ redirect(`/team/${team_slug}`); } - const transactionData = await getSingleTransaction({ - clientId: project.publishableKey, - teamId: project.teamId, - transactionId: id, - }); + const [transactionData, activityLogs] = await Promise.all([ + getSingleTransaction({ + clientId: project.publishableKey, + teamId: project.teamId, + transactionId: id, + }), + getTransactionActivityLogs({ + clientId: project.publishableKey, + teamId: project.teamId, + transactionId: id, + }), + ]); const client = getClientThirdwebClient({ jwt: authToken, @@ -41,10 +217,15 @@ export default async function TransactionPage({ notFound(); } + // Decode transaction data on the server + const decodedTransactionData = await decodeTransactionData(transactionData); + return (
- {`${transactionHash.slice(0, 8)}...${transactionHash.slice(-6)}`}{" "} + {`${transactionHash.slice( + 0, + 8, + )}...${transactionHash.slice(-6)}`}{" "} @@ -165,7 +188,10 @@ export function TransactionDetailsUI({ className="font-mono text-muted-foreground text-sm" copyIconPosition="left" textToCopy={transactionHash} - textToShow={`${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`} + textToShow={`${transactionHash.slice( + 0, + 6, + )}...${transactionHash.slice(-4)}`} tooltip="Copy transaction hash" variant="ghost" /> @@ -189,7 +215,7 @@ export function TransactionDetailsUI({ client={client} src={chain.icon?.url} /> - {chain.name} + {chain.name || "Unknown"}
) : (
Chain ID: {chainId || "Unknown"}
@@ -222,24 +248,10 @@ export function TransactionDetailsUI({
- - - Transaction Parameters - - - {transaction.transactionParams && - transaction.transactionParams.length > 0 ? ( - - ) : ( -

- No transaction parameters available -

- )} -
-
+ {errorMessage && ( @@ -250,7 +262,7 @@ export function TransactionDetailsUI({ {errorDetails ? ( ) : ( @@ -347,7 +359,348 @@ export function TransactionDetailsUI({ )} + + {/* Activity Log Card */} +
); } + +// Transaction Parameters Card with Tabs +function TransactionParametersCard({ + transaction, + decodedTransactionData, +}: { + transaction: Transaction; + decodedTransactionData: DecodedTransactionResult; +}) { + const [activeTab, setActiveTab] = useState<"decoded" | "raw">("decoded"); + + return ( + + + Transaction Parameters + + + setActiveTab("decoded"), + }, + { + isActive: activeTab === "raw", + name: "Raw", + onClick: () => setActiveTab("raw"), + }, + ]} + /> + + {activeTab === "decoded" ? ( + setActiveTab("raw")} + /> + ) : ( +
+ {transaction.transactionParams && + transaction.transactionParams.length > 0 ? ( + + ) : ( +

+ No transaction parameters available +

+ )} +
+ )} +
+
+ ); +} + +// Client component to display list of decoded transaction data +function DecodedTransactionListDisplay({ + decodedDataList, + onSwitchToRaw, +}: { + decodedDataList: DecodedTransactionResult; + onSwitchToRaw: () => void; +}) { + if (decodedDataList.length === 0) { + return ( +

+ Unable to decode transaction data. The contract may not have verified + metadata available.{" "} + + . +

+ ); + } + + return ( +
+ {decodedDataList.map( + (decodedData: DecodedTransactionData, index: number) => { + return ( +
+ {index > 0 &&
} + +
+ ); + }, + )} +
+ ); +} + +// Client component to display decoded transaction data +function DecodedTransactionDisplay({ + decodedData, + onSwitchToRaw, +}: { + decodedData: DecodedTransactionData; + onSwitchToRaw: () => void; +}) { + if (!decodedData) { + return ( +
+

+ Unable to decode transaction data. The contract may not have verified + metadata available.{" "} + + . +

+
+ ); + } + + return ( +
+
+
+
Target
+
+ + {decodedData.contractName} + +
+
+
+
Function
+
{decodedData.functionName}
+
+
+
Value
+
{decodedData.value}
+
+
+
+
Arguments
+ +
+
+ ); +} + +// Activity Log Timeline Component +function ActivityLogCard({ + activityLogs, +}: { + activityLogs: ActivityLogEntry[]; +}) { + // Sort activity logs and prepare JSX elements using for...of loop + const renderActivityLogs = () => { + if (activityLogs.length === 0) { + return ( +

+ No activity logs available for this transaction +

+ ); + } + + // Sort logs chronologically using for...of loop (manual sorting) + const sortedLogs: ActivityLogEntry[] = []; + + // Copy all logs to sortedLogs first + for (const log of activityLogs) { + sortedLogs[sortedLogs.length] = log; + } + + // Manual bubble sort using for...of loops + for (let i = 0; i < sortedLogs.length; i++) { + for (let j = 0; j < sortedLogs.length - 1 - i; j++) { + const currentLog = sortedLogs[j]; + const nextLog = sortedLogs[j + 1]; + + if ( + currentLog && + nextLog && + new Date(currentLog.createdAt).getTime() > + new Date(nextLog.createdAt).getTime() + ) { + // Swap elements + sortedLogs[j] = nextLog; + sortedLogs[j + 1] = currentLog; + } + } + } + + const logElements: React.ReactElement[] = []; + let index = 0; + + for (const log of sortedLogs) { + const isLast = index === sortedLogs.length - 1; + logElements.push( + , + ); + index++; + } + + return
{logElements}
; + }; + + return ( + + + Activity Log + + {renderActivityLogs()} + + ); +} + +function ActivityLogEntryItem({ + log, + isLast, +}: { + log: ActivityLogEntry; + isLast: boolean; +}) { + const [isExpanded, setIsExpanded] = useState(false); + + // Get display info based on event type + const getEventTypeInfo = (eventType: string) => { + const type = eventType.toLowerCase(); + if (type.includes("success")) + return { + dot: "bg-green-500", + label: "Success", + variant: "success" as const, + }; + if (type.includes("nack")) + return { + dot: "bg-yellow-500", + label: "Retry", + variant: "warning" as const, + }; + if (type.includes("failure")) + return { + dot: "bg-red-500", + label: "Error", + variant: "destructive" as const, + }; + return { + dot: "bg-primary", + label: eventType, + variant: "secondary" as const, + }; + }; + + const eventInfo = getEventTypeInfo(log.eventType); + + return ( +
+ {/* Timeline line */} + {!isLast && ( +
+ )} + +
+ {/* Timeline dot */} +
+
+
+ + {/* Content */} +
+ + + {isExpanded && ( +
+
+
+
Executor
+
{log.executorName}
+
+
+
Created At
+
+ {format(new Date(log.createdAt), "PP pp z")} +
+
+
+ + {log.payload && ( +
+
+ Payload +
+ +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx index 7c1a0342fd4..375d77fa65f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/index.tsx @@ -14,6 +14,7 @@ import { upload } from "thirdweb/storage"; import type { Project } from "@/api/projects"; import type { SMSCountryTiers } from "@/api/sms"; import type { Team } from "@/api/team"; +import { FileInput } from "@/components/blocks/FileInput"; import { GatedSwitch } from "@/components/blocks/GatedSwitch"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -41,7 +42,6 @@ import { } from "@/schema/validations"; import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; import { toArrFromList } from "@/utils/string"; -import { FileInput } from "../../../../../../../../../@/components/blocks/FileInput"; import CountrySelector from "./sms-country-select/country-selector"; type InAppWalletSettingsPageProps = { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx index 756d80db769..9b52ab8d644 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/settings/components/sms-country-select/country-selector.tsx @@ -1,3 +1,5 @@ +/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */ + import { CheckIcon, MinusIcon } from "lucide-react"; import type { SMSCountryTiers } from "@/api/sms"; import { Checkbox } from "@/components/ui/checkbox"; @@ -149,7 +151,6 @@ export default function CountrySelector({ toggleCountry(country); } }} - // biome-ignore lint/a11y/useSemanticElements: FIXME role="button" tabIndex={0} title={countryNames[country] || country} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx new file mode 100644 index 00000000000..c9a86b20aed --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { format } from "date-fns"; +import { useMemo } from "react"; +import { ResponsiveSuspense } from "responsive-rsc"; +import type { WebhookConfig } from "@/api/webhook-configs"; +import type { Range } from "@/components/analytics/date-range-selector"; +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import type { ChartConfig } from "@/components/ui/chart"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { DateRangeControls, WebhookPicker } from "./WebhookAnalyticsFilter"; + +interface WebhookAnalyticsChartsProps { + webhookConfigs: WebhookConfig[]; + range: Range; + interval: "day" | "week"; + teamId: string; + projectId: string; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhookAnalyticsCharts({ + webhookConfigs, + range, + requestsData, + latencyData, + selectedWebhookId, +}: WebhookAnalyticsChartsProps) { + return ( + + ); +} + +function WebhookAnalyticsChartsUI({ + webhookConfigs, + requestsData, + latencyData, + range, + selectedWebhookId, +}: { + webhookConfigs: WebhookConfig[]; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + range: Range; + selectedWebhookId: string; +}) { + // Filter data based on selected webhook and date range + const filteredRequestsData = useMemo(() => { + let data = requestsData; + + // Note: webhook filtering is already done server-side, + // but we still apply date range filtering for consistency + data = data.filter((item) => { + const itemDate = new Date(item.date); + return itemDate >= range.from && itemDate <= range.to; + }); + + return data; + }, [requestsData, range.from, range.to]); + + const filteredLatencyData = useMemo(() => { + let data = latencyData; + + // Note: webhook filtering is already done server-side, + // but we still apply date range filtering for consistency + data = data.filter((item) => { + const itemDate = new Date(item.date); + return itemDate >= range.from && itemDate <= range.to; + }); + + return data; + }, [latencyData, range.from, range.to]); + + // Process status code distribution data by individual status codes + const statusCodeData = useMemo(() => { + if (!filteredRequestsData.length) return []; + + const groupedData = filteredRequestsData.reduce( + (acc, item) => { + const date = new Date(item.date).getTime(); + if (!acc[date]) { + acc[date] = { time: date }; + } + + // Only include valid status codes (not 0) with actual request counts + if (item.httpStatusCode > 0 && item.totalRequests > 0) { + const statusKey = item.httpStatusCode.toString(); + acc[date][statusKey] = + (acc[date][statusKey] || 0) + item.totalRequests; + } + return acc; + }, + {} as Record & { time: number }>, + ); + + return Object.values(groupedData).sort( + (a, b) => (a.time || 0) - (b.time || 0), + ); + }, [filteredRequestsData]); + + // Process latency data for charts + const latencyChartData = useMemo(() => { + if (!filteredLatencyData.length) return []; + + return filteredLatencyData + .map((item) => ({ + p50: item.p50LatencyMs, + p90: item.p90LatencyMs, + p99: item.p99LatencyMs, + time: new Date(item.date).getTime(), + })) + .sort((a, b) => a.time - b.time); + }, [filteredLatencyData]); + + // Chart configurations + const latencyChartConfig: ChartConfig = { + p50: { + color: "hsl(var(--chart-1))", + label: "P50 Latency", + }, + p90: { + color: "hsl(var(--chart-2))", + label: "P90 Latency", + }, + p99: { + color: "hsl(var(--chart-3))", + label: "P99 Latency", + }, + }; + + // Generate status code chart config dynamically with class-based colors + const statusCodeConfig: ChartConfig = useMemo(() => { + const statusCodes = new Set(); + statusCodeData.forEach((item) => { + Object.keys(item).forEach((key) => { + if (key !== "time" && !Number.isNaN(Number.parseInt(key))) { + statusCodes.add(key); + } + }); + }); + + const getColorForStatusCode = (statusCode: number): string => { + if (statusCode >= 200 && statusCode < 300) { + return "hsl(142, 76%, 36%)"; // Green for 2xx + } else if (statusCode >= 300 && statusCode < 400) { + return "hsl(48, 96%, 53%)"; // Yellow for 3xx + } else if (statusCode >= 400 && statusCode < 500) { + return "hsl(25, 95%, 53%)"; // Orange for 4xx + } else { + return "hsl(0, 84%, 60%)"; // Red for 5xx + } + }; + + const config: ChartConfig = {}; + Array.from(statusCodes) + .sort((a, b) => { + const codeA = Number.parseInt(a); + const codeB = Number.parseInt(b); + return codeA - codeB; + }) + .forEach((statusKey) => { + const statusCode = Number.parseInt(statusKey); + config[statusKey] = { + color: getColorForStatusCode(statusCode), + label: statusCode.toString(), + }; + }); + + return config; + }, [statusCodeData]); + + const hasData = statusCodeData.length > 0 || latencyChartData.length > 0; + const selectedWebhookConfig = webhookConfigs.find( + (w) => w.id === selectedWebhookId, + ); + + return ( +
+
+ + +
+ + {/* Selected webhook URL */} + {selectedWebhookConfig && selectedWebhookId !== "all" && ( +
+ +
+ )} + + {!hasData ? ( +
+
+

+ No webhook data available +

+

+ Webhook analytics will appear here once you start receiving + webhook events. +

+
+
+ ) : ( + + + +
+ } + searchParamsUsed={["from", "to", "interval", "webhook"]} + > +
+ {/* Status Code Distribution Chart */} + + format( + new Date(Number.parseInt(label as string)), + "MMM dd, yyyy HH:mm", + ) + } + toolTipValueFormatter={(value) => `${value} requests`} + variant="stacked" + /> + + {/* Latency Chart */} + + format( + new Date(Number.parseInt(label as string)), + "MMM dd, yyyy HH:mm", + ) + } + toolTipValueFormatter={(value) => `${value}ms`} + /> +
+ + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx new file mode 100644 index 00000000000..53da6e6417f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "@/components/analytics/date-range-selector"; +import { IntervalSelector } from "@/components/analytics/interval-selector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time"; + +type SearchParams = { + from?: string; + to?: string; + interval?: "day" | "week"; +}; + +interface WebhookAnalyticsFilterProps { + webhookConfigs: Array<{ + id: string; + description: string | null; + }>; + selectedWebhookId: string; +} + +export function WebhookPicker({ + webhookConfigs, + selectedWebhookId, +}: WebhookAnalyticsFilterProps) { + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + return ( + + ); +} + +export function DateRangeControls() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: responsiveSearchParams.from, + interval: responsiveSearchParams.interval, + to: responsiveSearchParams.to, + }); + + return ( +
+ { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + return newParams; + }); + }} + /> + + { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + interval: newInterval, + }; + return newParams; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx new file mode 100644 index 00000000000..3d83b0a319f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx @@ -0,0 +1,42 @@ +import type { WebhookConfig } from "@/api/webhook-configs"; +import type { Range } from "@/components/analytics/date-range-selector"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { WebhookAnalyticsCharts } from "./WebhookAnalyticsCharts"; + +interface WebhookAnalyticsServerProps { + teamId: string; + projectId: string; + webhookConfigs: WebhookConfig[]; + range: Range; + interval: "day" | "week"; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhookAnalyticsServer({ + teamId, + projectId, + webhookConfigs, + range, + interval, + requestsData, + latencyData, + selectedWebhookId, +}: WebhookAnalyticsServerProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx new file mode 100644 index 00000000000..a11971b1450 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx @@ -0,0 +1,44 @@ +import type { WebhookConfig } from "@/api/webhook-configs"; +import type { Range } from "@/components/analytics/date-range-selector"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { WebhookAnalyticsServer } from "./WebhookAnalyticsServer"; + +interface WebhooksAnalyticsProps { + teamId: string; + teamSlug: string; + projectId: string; + projectSlug: string; + range: Range; + interval: "day" | "week"; + webhookConfigs: WebhookConfig[]; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhooksAnalytics({ + teamId, + projectId, + range, + interval, + webhookConfigs, + requestsData, + latencyData, + selectedWebhookId, +}: WebhooksAnalyticsProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx new file mode 100644 index 00000000000..fa0af1af7ac --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx @@ -0,0 +1,117 @@ +import { notFound } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +import { getWebhookLatency, getWebhookRequests } from "@/api/analytics"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/projects"; +import { getWebhookConfigs } from "@/api/webhook-configs"; +import { getFiltersFromSearchParams } from "@/lib/time"; +import { WebhooksAnalytics } from "./components/WebhooksAnalytics"; + +export default async function WebhooksAnalyticsPage(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; + webhook?: string | undefined | string[]; + }>; +}) { + const [authToken, params] = await Promise.all([getAuthToken(), props.params]); + + const project = await getProject(params.team_slug, params.project_slug); + + if (!project || !authToken) { + notFound(); + } + + const searchParams = await props.searchParams; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-7", + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); + + // Get webhook configs + const webhookConfigsResponse = await getWebhookConfigs({ + projectIdOrSlug: params.project_slug, + teamIdOrSlug: params.team_slug, + }).catch(() => ({ + body: "", + data: [], + reason: "Failed to fetch webhook configs", + status: "error" as const, + })); + + if ( + webhookConfigsResponse.status === "error" || + webhookConfigsResponse.data.length === 0 + ) { + return ( + +
+

+ No webhook configurations found. +

+
+
+ ); + } + + // Get selected webhook ID from search params + const selectedWebhookId = Array.isArray(searchParams.webhook) + ? searchParams.webhook[0] || "all" + : searchParams.webhook || "all"; + + // Fetch webhook analytics data + const webhookId = selectedWebhookId === "all" ? undefined : selectedWebhookId; + const [requestsData, latencyData] = await Promise.all([ + (async () => { + const res = await getWebhookRequests({ + from: range.from, + period: interval, + projectId: project.id, + teamId: project.teamId, + to: range.to, + webhookId, + }); + if ("error" in res) { + console.error("Failed to fetch webhook requests:", res.error); + return []; + } + return res.data; + })(), + (async () => { + const res = await getWebhookLatency({ + from: range.from, + period: interval, + projectId: project.id, + teamId: project.teamId, + to: range.to, + webhookId, + }); + if ("error" in res) { + console.error("Failed to fetch webhook latency:", res.error); + return []; + } + return res.data; + })(), + ]); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx index a9289d6c802..8092c394640 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx @@ -1,6 +1,5 @@ import { DialogDescription } from "@radix-ui/react-dialog"; import { AlertTriangleIcon } from "lucide-react"; -import type { WebhookConfig } from "@/api/webhook-configs"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -11,12 +10,12 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { useWebhookMetrics } from "../hooks/use-webhook-metrics"; +import type { WebhookSummaryStats } from "@/types/analytics"; +import type { WebhookConfig } from "../../../../../../../../@/api/webhook-configs"; interface DeleteWebhookModalProps { webhookConfig: WebhookConfig | null; - teamId: string; - projectId: string; + metrics: WebhookSummaryStats | null; onConfirm: () => void; isPending: boolean; open: boolean; @@ -24,19 +23,12 @@ interface DeleteWebhookModalProps { } export function DeleteWebhookModal(props: DeleteWebhookModalProps) { - const { data: metrics } = useWebhookMetrics({ - enabled: props.open && !!props.webhookConfig?.id, - projectId: props.projectId, - teamId: props.teamId, - webhookId: props.webhookConfig?.id || "", - }); - if (!props.webhookConfig) { return null; } // Use real metrics data - const requests24h = metrics?.totalRequests ?? 0; + const requests24h = props.metrics?.totalRequests ?? 0; const hasRecentActivity = requests24h > 0; return ( diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx index a10f8dff04d..8580e384aa4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx @@ -1,10 +1,11 @@ "use client"; import { redirect } from "next/navigation"; -import posthog from "posthog-js"; -import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; -import { useAvailableTopics } from "../hooks/use-available-topics"; -import { useWebhookConfigs } from "../hooks/use-webhook-configs"; +import type { WebhookSummaryStats } from "@/types/analytics"; +import type { + Topic, + WebhookConfig, +} from "../../../../../../../../@/api/webhook-configs"; import { WebhookConfigsTable } from "./webhook-configs-table"; interface WebhooksOverviewProps { @@ -12,6 +13,9 @@ interface WebhooksOverviewProps { teamSlug: string; projectId: string; projectSlug: string; + webhookConfigs: WebhookConfig[]; + topics: Topic[]; + metricsMap: Map; } export function WebhooksOverview({ @@ -19,48 +23,28 @@ export function WebhooksOverview({ teamSlug, projectId, projectSlug, + webhookConfigs, + topics, + metricsMap, }: WebhooksOverviewProps) { - // Enabled on dev or if FF is enabled. - const isFeatureEnabled = - !posthog.__loaded || posthog.isFeatureEnabled("centralized-webhooks"); - - const webhookConfigsQuery = useWebhookConfigs({ - enabled: isFeatureEnabled, - projectSlug, - teamSlug, - }); - const topicsQuery = useAvailableTopics({ enabled: isFeatureEnabled }); + // Feature is enabled (matches server component behavior) + const isFeatureEnabled = true; // Redirect to contracts tab if feature is disabled if (!isFeatureEnabled) { redirect(`/team/${teamSlug}/${projectSlug}/webhooks/contracts`); } - // Show loading while data is loading - if (webhookConfigsQuery.isPending || topicsQuery.isPending) { - return ; - } - - // Show error state - if (webhookConfigsQuery.error || topicsQuery.error) { - return ( -
-

- Failed to load webhook data. Please try again. -

-
- ); - } - // Show full webhook functionality return ( ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx index 07eaf6b5722..fae532bd0ca 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx @@ -91,8 +91,8 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) { webhookConfigId: webhookConfig.id, }); - if (result.error) { - throw new Error(result.error); + if (result.status === "error") { + throw new Error(result.body); } return result.data; @@ -103,8 +103,8 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) { teamIdOrSlug: props.teamSlug, }); - if (result.error) { - throw new Error(result.error); + if (result.status === "error") { + throw new Error(result.body); } return result.data; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx index d2f75b67576..e456c258a79 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx @@ -15,8 +15,6 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; -import type { Topic, WebhookConfig } from "@/api/webhook-configs"; -import { deleteWebhookConfig } from "@/api/webhook-configs"; import { PaginationButtons } from "@/components/blocks/pagination-buttons"; import { Button } from "@/components/ui/button"; import { @@ -37,6 +35,12 @@ import { } from "@/components/ui/table"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import type { WebhookSummaryStats } from "@/types/analytics"; +import type { + Topic, + WebhookConfig, +} from "../../../../../../../../@/api/webhook-configs"; +import { deleteWebhookConfig } from "../../../../../../../../@/api/webhook-configs"; import { CreateWebhookConfigModal } from "./create-webhook-config-modal"; import { DeleteWebhookModal } from "./delete-webhook-modal"; import { EditWebhookConfigModal } from "./edit-webhook-config-modal"; @@ -51,6 +55,7 @@ export function WebhookConfigsTable(props: { projectSlug: string; webhookConfigs: WebhookConfig[]; topics: Topic[]; + metricsMap: Map; }) { const { webhookConfigs } = props; const [sortBy, setSortBy] = useState("createdAt"); @@ -71,8 +76,8 @@ export function WebhookConfigsTable(props: { webhookConfigId: webhookId, }); - if (result.error) { - throw new Error(result.error); + if (result.status === "error") { + throw new Error(result.body); } return result.data; @@ -228,9 +233,7 @@ export function WebhookConfigsTable(props: { @@ -309,6 +312,11 @@ export function WebhookConfigsTable(props: { { if (deletingWebhook) { deleteMutation.mutate(deletingWebhook.id); @@ -318,8 +326,6 @@ export function WebhookConfigsTable(props: { if (!open) setDeletingWebhook(null); }} open={!!deletingWebhook} - projectId={props.projectId} - teamId={props.teamId} webhookConfig={deletingWebhook} />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx index f1e17746c37..00523fdc996 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx @@ -1,31 +1,13 @@ "use client"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { useWebhookMetrics } from "../hooks/use-webhook-metrics"; +import type { WebhookSummaryStats } from "@/types/analytics"; interface WebhookMetricsProps { - webhookId: string; - teamId: string; - projectId: string; + metrics: WebhookSummaryStats | null; isPaused: boolean; } -export function WebhookMetrics({ - webhookId, - teamId, - projectId, - isPaused, -}: WebhookMetricsProps) { - const { - data: metrics, - isLoading, - error, - } = useWebhookMetrics({ - projectId, - teamId, - webhookId, - }); - +export function WebhookMetrics({ metrics, isPaused }: WebhookMetricsProps) { if (isPaused) { return ( @@ -34,25 +16,14 @@ export function WebhookMetrics({ ); } - if (isLoading) { + if (!metrics) { return ( -
- - Loading... -
- ); - } - - if (error) { - return ( -
- Failed to load metrics -
+
No metrics available
); } - const totalRequests = metrics?.totalRequests ?? 0; - const errorRequests = metrics?.errorRequests ?? 0; + const totalRequests = metrics.totalRequests ?? 0; + const errorRequests = metrics.errorRequests ?? 0; const errorRate = totalRequests > 0 ? (errorRequests / totalRequests) * 100 : 0; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts deleted file mode 100644 index 4799ac53783..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { getAvailableTopics } from "@/api/webhook-configs"; - -export function useAvailableTopics({ - enabled = true, -}: { - enabled?: boolean; -} = {}) { - return useQuery({ - enabled, - queryFn: async () => { - const result = await getAvailableTopics(); - - if (result.error) { - throw new Error(result.error); - } - - return result.data || []; - }, - queryKey: ["webhook-topics"], - }); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts deleted file mode 100644 index 44c54c4d4b0..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { getWebhookConfigs } from "@/api/webhook-configs"; - -interface UseWebhookConfigsParams { - teamSlug: string; - projectSlug: string; - enabled?: boolean; -} - -export function useWebhookConfigs({ - teamSlug, - projectSlug, - enabled = true, -}: UseWebhookConfigsParams) { - return useQuery({ - enabled, - queryFn: async () => { - const result = await getWebhookConfigs({ - projectIdOrSlug: projectSlug, - teamIdOrSlug: teamSlug, - }); - - if (result.error) { - throw new Error(result.error); - } - - return result.data || []; - }, - queryKey: ["webhook-configs", teamSlug, projectSlug], - }); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts deleted file mode 100644 index 813c1d5f146..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { getWebhookMetricsAction } from "@/api/webhook-metrics"; -import type { WebhookSummaryStats } from "@/types/analytics"; - -interface UseWebhookMetricsParams { - webhookId: string; - teamId: string; - projectId: string; - enabled?: boolean; -} - -export function useWebhookMetrics({ - webhookId, - teamId, - projectId, - enabled = true, -}: UseWebhookMetricsParams) { - return useQuery({ - enabled: enabled && !!webhookId, - queryFn: async (): Promise => { - return await getWebhookMetricsAction({ - from: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago - period: "day", - projectId, - teamId, - to: new Date(), - webhookId, - }); - }, - queryKey: ["webhook-metrics", teamId, projectId, webhookId], - retry: 1, - staleTime: 5 * 60 * 1000, - }); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx index bc6c3280fb3..93925420b59 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx @@ -1,4 +1,5 @@ -import posthog from "posthog-js"; +import { getValidAccount } from "@app/account/settings/getAccount"; +import { isFeatureFlagEnabled } from "@/analytics/posthog-server"; import { TabPathLinks } from "@/components/ui/tabs"; export default async function WebhooksLayout(props: { @@ -8,9 +9,11 @@ export default async function WebhooksLayout(props: { project_slug: string; }>; }) { - // Enabled on dev or if FF is enabled. - const isFeatureEnabled = - !posthog.__loaded || posthog.isFeatureEnabled("centralized-webhooks"); + const account = await getValidAccount(); + const isFeatureEnabled = await isFeatureFlagEnabled( + "webhook-analytics-tab", + account.email, + ); const params = await props.params; return ( @@ -35,6 +38,11 @@ export default async function WebhooksLayout(props: { name: "Overview", path: `/team/${params.team_slug}/${params.project_slug}/webhooks`, }, + { + exactMatch: true, + name: "Analytics", + path: `/team/${params.team_slug}/${params.project_slug}/webhooks/analytics`, + }, ] : []), { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx index 99d50240bd7..653389e2c14 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx @@ -1,6 +1,11 @@ import { notFound } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/projects"; +import { getWebhookSummary } from "../../../../../../../@/api/analytics"; +import { + getAvailableTopics, + getWebhookConfigs, +} from "../../../../../../../@/api/webhook-configs"; import { WebhooksOverview } from "./components/overview"; export default async function WebhooksPage({ @@ -22,12 +27,59 @@ export default async function WebhooksPage({ notFound(); } + // Fetch webhook configs and topics in parallel + const [webhookConfigsResult, topicsResult] = await Promise.all([ + getWebhookConfigs({ + projectIdOrSlug: resolvedParams.project_slug, + teamIdOrSlug: resolvedParams.team_slug, + }), + getAvailableTopics(), + ]); + + if ( + webhookConfigsResult.status === "error" || + topicsResult.status === "error" + ) { + notFound(); + } + + const webhookConfigs = webhookConfigsResult.data || []; + const topics = topicsResult.data || []; + + // Fetch metrics for all webhooks in parallel + const webhookMetrics = await Promise.all( + webhookConfigs.map(async (config) => { + const metricsResult = await getWebhookSummary({ + from: new Date(Date.now() - 24 * 60 * 60 * 1000), + period: "day", + projectId: project.id, + teamId: project.teamId, // 24 hours ago + to: new Date(), + webhookId: config.id, + }); + + return { + metrics: + "error" in metricsResult ? null : (metricsResult.data[0] ?? null), + webhookId: config.id, + }; + }), + ); + + // Create a map for easy lookup + const metricsMap = new Map( + webhookMetrics.map((item) => [item.webhookId, item.metrics]), + ); + return ( ); } diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx index 006cd302118..a2f5af346f0 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */ "use client"; import { ChevronsUpDownIcon } from "lucide-react"; @@ -64,7 +65,6 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) { aria-expanded={open} aria-label={`Select a ${props.focus === "project-selection" ? "project" : "team"}`} className="!h-auto w-auto rounded-xl px-1 py-2" - // biome-ignore lint/a11y/useSemanticElements: EXPECTED role="combobox" size="icon" variant="ghost" diff --git a/apps/dashboard/src/app/bridge/constants.ts b/apps/dashboard/src/app/bridge/constants.ts index 95b7fdbc0ed..f5bd30febe5 100644 --- a/apps/dashboard/src/app/bridge/constants.ts +++ b/apps/dashboard/src/app/bridge/constants.ts @@ -31,6 +31,20 @@ function getBridgeThirdwebClient() { }); } + // During build time, client ID might not be available + if (!NEXT_PUBLIC_DASHBOARD_CLIENT_ID) { + // Return a minimal client that will fail gracefully at runtime if needed + return createThirdwebClient({ + clientId: "dummy-build-time-client", + config: { + storage: { + gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL, + }, + }, + secretKey: undefined, + }); + } + return createThirdwebClient({ clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID, config: { diff --git a/apps/dashboard/src/app/pay/constants.ts b/apps/dashboard/src/app/pay/constants.ts index 6000afb43c1..5d9a0fc13df 100644 --- a/apps/dashboard/src/app/pay/constants.ts +++ b/apps/dashboard/src/app/pay/constants.ts @@ -31,6 +31,20 @@ function getPayThirdwebClient() { }); } + // During build time, client ID might not be available + if (!NEXT_PUBLIC_DASHBOARD_CLIENT_ID) { + // Return a minimal client that will fail gracefully at runtime if needed + return createThirdwebClient({ + clientId: "dummy-build-time-client", + config: { + storage: { + gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL, + }, + }, + secretKey: undefined, + }); + } + return createThirdwebClient({ clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID, config: { diff --git a/apps/login/package.json b/apps/login/package.json new file mode 100644 index 00000000000..8a2d4fc1c11 --- /dev/null +++ b/apps/login/package.json @@ -0,0 +1,5 @@ +{ + "name": "login", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/apps/nebula/biome.json b/apps/nebula/biome.json index cec0f72abd0..f9869db792f 100644 --- a/apps/nebula/biome.json +++ b/apps/nebula/biome.json @@ -1,4 +1,4 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", "extends": "//" } diff --git a/apps/nebula/package.json b/apps/nebula/package.json index 2fc8a6e3d13..9a59c849a5b 100644 --- a/apps/nebula/package.json +++ b/apps/nebula/package.json @@ -12,19 +12,19 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tooltip": "1.2.7", - "@tanstack/react-query": "5.80.7", + "@tanstack/react-query": "5.81.5", "@vercel/functions": "2.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "4.1.0", "fetch-event-stream": "0.1.5", "fuse.js": "7.1.0", - "lucide-react": "0.514.0", - "next": "15.3.3", + "lucide-react": "0.525.0", + "next": "15.3.5", "next-themes": "^0.4.6", "nextjs-toploader": "^1.6.12", - "posthog-js": "1.252.0", - "prettier": "3.5.3", + "posthog-js": "1.256.1", + "prettier": "3.6.2", "react": "19.1.0", "react-children-utilities": "^2.10.0", "react-dom": "19.1.0", @@ -33,20 +33,20 @@ "remark-gfm": "4.0.1", "server-only": "^0.0.1", "shiki": "1.27.0", - "sonner": "2.0.5", + "sonner": "2.0.6", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "thirdweb": "workspace:*", - "zod": "3.25.67" + "zod": "3.25.75" }, "devDependencies": { - "@biomejs/biome": "2.0.4", - "@chromatic-com/storybook": "4.0.0", - "@next/eslint-plugin-next": "15.3.3", - "@storybook/addon-docs": "9.0.8", - "@storybook/addon-links": "9.0.8", - "@storybook/addon-onboarding": "9.0.8", - "@storybook/nextjs": "9.0.8", + "@biomejs/biome": "2.0.6", + "@chromatic-com/storybook": "4.0.1", + "@next/eslint-plugin-next": "15.3.5", + "@storybook/addon-docs": "9.0.15", + "@storybook/addon-links": "9.0.15", + "@storybook/addon-onboarding": "9.0.15", + "@storybook/nextjs": "9.0.15", "@types/node": "22.14.1", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", @@ -56,11 +56,11 @@ "eslint": "8.57.0", "eslint-config-biome": "1.9.4", "eslint-plugin-react-compiler": "19.1.0-rc.2", - "eslint-plugin-storybook": "9.0.8", + "eslint-plugin-storybook": "9.0.15", "knip": "5.60.2", "next-sitemap": "^4.2.3", - "postcss": "8.5.5", - "storybook": "9.0.8", + "postcss": "8.5.6", + "storybook": "9.0.15", "tailwindcss": "3.4.17", "typescript": "5.8.3" }, diff --git a/apps/nebula/src/@/components/blocks/select-with-search.tsx b/apps/nebula/src/@/components/blocks/select-with-search.tsx index d9ee03ffbb3..b940c325973 100644 --- a/apps/nebula/src/@/components/blocks/select-with-search.tsx +++ b/apps/nebula/src/@/components/blocks/select-with-search.tsx @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/a11y/useSemanticElements: TODO */ "use client"; import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; @@ -193,7 +194,6 @@ export const SelectWithSearch = React.forwardRef< ref={ i === optionsToShow.length - 1 ? lastItemRef : undefined } - // biome-ignore lint/a11y/useSemanticElements: TODO role="option" variant="ghost" > diff --git a/apps/nebula/src/@/components/blocks/wallet-address.tsx b/apps/nebula/src/@/components/blocks/wallet-address.tsx index 3ee3086c92b..e985a079f42 100644 --- a/apps/nebula/src/@/components/blocks/wallet-address.tsx +++ b/apps/nebula/src/@/components/blocks/wallet-address.tsx @@ -59,7 +59,11 @@ export function WalletAddress(props: { // special case for zero address if (address === ZERO_ADDRESS) { - return {shortenedAddress}; + return ( + + {shortenedAddress} + + ); } return ( diff --git a/apps/nebula/src/app/(app)/components/ChatBar.tsx b/apps/nebula/src/app/(app)/components/ChatBar.tsx index c67257b324a..2d079c37db8 100644 --- a/apps/nebula/src/app/(app)/components/ChatBar.tsx +++ b/apps/nebula/src/app/(app)/components/ChatBar.tsx @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/a11y/useSemanticElements: TODO */ "use client"; import { useMutation } from "@tanstack/react-query"; @@ -617,7 +618,6 @@ function WalletSelector(props: { props.onClick(wallet); } }} - // biome-ignore lint/a11y/useSemanticElements: TODO role="button" tabIndex={0} > diff --git a/apps/playground-web/biome.json b/apps/playground-web/biome.json index cec0f72abd0..f9869db792f 100644 --- a/apps/playground-web/biome.json +++ b/apps/playground-web/biome.json @@ -1,4 +1,4 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", "extends": "//" } diff --git a/apps/playground-web/package.json b/apps/playground-web/package.json index c8c8cd40f2e..4e490615e85 100644 --- a/apps/playground-web/package.json +++ b/apps/playground-web/package.json @@ -12,17 +12,17 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tooltip": "1.2.7", - "@tanstack/react-query": "5.80.7", + "@tanstack/react-query": "5.81.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "4.1.0", - "lucide-react": "0.514.0", - "next": "15.3.3", + "lucide-react": "0.525.0", + "next": "15.3.5", "next-themes": "^0.4.6", "nextjs-toploader": "^1.6.12", "openapi-types": "^12.1.3", - "posthog-js": "1.252.0", - "prettier": "3.5.3", + "posthog-js": "1.256.1", + "prettier": "3.6.2", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "7.55.0", @@ -32,20 +32,20 @@ "tailwind-merge": "^2.6.0", "thirdweb": "workspace:*", "use-debounce": "^10.0.5", - "zod": "3.25.67" + "zod": "3.25.75" }, "devDependencies": { - "@biomejs/biome": "2.0.4", + "@biomejs/biome": "2.0.6", "@types/node": "22.14.1", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "autoprefixer": "^10.4.21", "eslint": "8.57.0", "eslint-config-biome": "1.9.4", - "eslint-config-next": "15.3.3", + "eslint-config-next": "15.3.5", "eslint-plugin-react-compiler": "19.1.0-rc.2", "knip": "5.60.2", - "postcss": "8.5.5", + "postcss": "8.5.6", "tailwindcss": "3.4.17", "tailwindcss-animate": "^1.0.7", "typescript": "5.8.3" @@ -57,6 +57,7 @@ "dev": "rm -rf .next && next dev --turbopack", "fix": "eslint ./src --fix", "format": "biome format ./src --write", + "knip": "knip", "lint": "biome check ./src && knip && eslint ./src", "prefix": "biome check ./src --fix", "prelint": "biome check ./src", diff --git a/apps/playground-web/src/app/connect/in-app-wallet/page.tsx b/apps/playground-web/src/app/connect/in-app-wallet/page.tsx index 6e51d5db370..0cde2f5a711 100644 --- a/apps/playground-web/src/app/connect/in-app-wallet/page.tsx +++ b/apps/playground-web/src/app/connect/in-app-wallet/page.tsx @@ -41,43 +41,44 @@ function UIIntegration() { return (
); -};`} +function App() { + return ; +}`} header={{ description: "Instant out of the box authentication with a prebuilt UI.", diff --git a/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx b/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx index 5fb7f46ecad..156792ec7e5 100644 --- a/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx +++ b/apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx @@ -12,11 +12,14 @@ import type React from "react"; import { useId, useState } from "react"; import type { Address } from "thirdweb"; import { defineChain } from "thirdweb/chains"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { cn } from "../../../../lib/utils"; +import { TokenSelector } from "@/components/ui/TokenSelector"; +import { THIRDWEB_CLIENT } from "@/lib/client"; +import type { TokenMetadata } from "@/lib/types"; import { CollapsibleSection } from "../../sign-in/components/CollapsibleSection"; import { ColorFormGroup } from "../../sign-in/components/ColorFormGroup"; import type { BridgeComponentsPlaygroundOptions } from "../components/types"; @@ -39,17 +42,27 @@ export function LeftSection(props: { })); }; - const [tokenAddress, setTokenAddress] = useState( - payOptions.buyTokenAddress || "", - ); + // Shared state for chain and token selection (used by both Buy and Checkout modes) + const [selectedChain, setSelectedChain] = useState(() => { + return payOptions.buyTokenChain?.id; + }); + + const [selectedToken, setSelectedToken] = useState< + { chainId: number; address: string } | undefined + >(() => { + if (payOptions.buyTokenAddress && payOptions.buyTokenChain?.id) { + return { + address: payOptions.buyTokenAddress, + chainId: payOptions.buyTokenChain.id, + }; + } + return undefined; + }); const payModeId = useId(); const buyTokenAmountId = useId(); - const buyTokenChainId = useId(); - const tokenAddressId = useId(); const sellerAddressId = useId(); const paymentAmountId = useId(); - const directPaymentChainId = useId(); const modalTitleId = useId(); const modalTitleIconId = useId(); const modalDescriptionId = useId(); @@ -57,6 +70,37 @@ export function LeftSection(props: { const cryptoPaymentId = useId(); const cardPaymentId = useId(); + const handleChainChange = (chainId: number) => { + setSelectedChain(chainId); + // Clear token selection when chain changes + setSelectedToken(undefined); + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAddress: undefined, + buyTokenChain: defineChain(chainId), // Clear token when chain changes + }, + })); + }; + + const handleTokenChange = (token: TokenMetadata) => { + const newSelectedToken = { + address: token.address, + chainId: token.chainId, + }; + setSelectedToken(newSelectedToken); + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAddress: token.address as Address, + }, + })); + }; + return (
- {/* Conditional form fields based on selected mode */} -
- {/* Fund Wallet Mode Options */} + {/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */} + {(!payOptions.widget || + payOptions.widget === "buy" || + payOptions.widget === "checkout") && ( +
+ {/* Chain selection */} +
+ + +
+ + {/* Token selection - only show if chain is selected */} + {selectedChain && ( +
+ + +
+ )} +
+ )} + + {/* Mode-specific form fields */} +
+ {/* Buy Mode - Amount and Payment Methods */} {(!payOptions.widget || payOptions.widget === "buy") && (
-
-
- - - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - buyTokenAmount: e.target.value, - }, - })) - } - placeholder="0.01" - value={payOptions.buyTokenAmount || ""} - /> -
+
+ + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAmount: e.target.value, + }, + })) + } + placeholder="0.01" + value={payOptions.buyTokenAmount || ""} + /> +
- {/* Chain selection */} -
- - { - const chainId = Number.parseInt(e.target.value); - if (!Number.isNaN(chainId)) { - const chain = defineChain(chainId); + {/* Payment Methods */} +
+ +
+
+ { setOptions((v) => ({ ...v, payOptions: { ...v.payOptions, - buyTokenChain: chain, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), + "crypto", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), }, })); - } - }} - placeholder="1 (Ethereum)" - type="text" - value={payOptions.buyTokenChain?.id || ""} - /> -
-
- - {/* Token selection for fund_wallet mode */} -
-
-
-
- - { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - buyTokenAddress: e.target.value as Address, - }, - })); - }} - placeholder="0x..." - value={payOptions.buyTokenAddress} - /> -
+ }} + /> +
- - {/* Payment Methods */} -
- -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - "crypto", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - }, - })); - }} - /> - -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - "card", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - }, - })); - }} - /> - -
-
+
+ { + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + "card", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + }, + })); + }} + /> +
)} - {/* Direct Payment Mode Options */} + {/* Checkout Mode - Seller Address, Price and Payment Methods */} {payOptions.widget === "checkout" && (
@@ -250,125 +273,80 @@ export function LeftSection(props: { />
-
-
- - - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - buyTokenAmount: e.target.value, - }, - })) - } - placeholder="0.01" - value={payOptions.buyTokenAmount || ""} - /> -
+
+ + + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + buyTokenAmount: e.target.value, + }, + })) + } + placeholder="0.01" + value={payOptions.buyTokenAmount || ""} + /> +
- {/* Chain selection */} -
- - { - const chainId = Number.parseInt(e.target.value); - if (!Number.isNaN(chainId)) { - const chain = defineChain(chainId); + {/* Payment Methods */} +
+ +
+
+ { setOptions((v) => ({ ...v, payOptions: { ...v.payOptions, - buyTokenChain: chain, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), + "crypto", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "crypto", + ), }, })); - } - }} - placeholder="1 (Ethereum)" - type="number" - value={payOptions.buyTokenChain?.id || ""} - /> -
-
- - {/* Token selection for direct_payment mode - shares state with fund_wallet mode */} -
-
-
-
- - setTokenAddress(e.target.value)} - placeholder="0x..." - value={tokenAddress} - /> -
+ }} + /> +
- - {/* Payment Methods */} -
- -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - "crypto", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "crypto", - ), - }, - })); - }} - /> - -
-
- { - setOptions((v) => ({ - ...v, - payOptions: { - ...v.payOptions, - paymentMethods: checked - ? [ - ...v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - "card", - ] - : v.payOptions.paymentMethods.filter( - (m) => m !== "card", - ), - }, - })); - }} - /> - -
-
+
+ { + setOptions((v) => ({ + ...v, + payOptions: { + ...v.payOptions, + paymentMethods: checked + ? [ + ...v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + "card", + ] + : v.payOptions.paymentMethods.filter( + (m) => m !== "card", + ), + }, + })); + }} + /> +
diff --git a/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx b/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx index e95d746c0de..a8267404768 100644 --- a/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx +++ b/apps/playground-web/src/app/connect/sign-in/button/RightSection.tsx @@ -3,10 +3,15 @@ import { XIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { + abstract, + arbitrum, arbitrumSepolia, + base, baseSepolia, - defineChain, + ethereum, + optimism, optimismSepolia, + polygon, sepolia, } from "thirdweb/chains"; import { @@ -115,7 +120,12 @@ export function RightSection(props: { auth={connectOptions.enableAuth ? playgroundAuth : undefined} autoConnect={false} chains={[ - defineChain(578), + base, + ethereum, + polygon, + optimism, + arbitrum, + abstract, sepolia, baseSepolia, optimismSepolia, diff --git a/apps/playground-web/src/app/layout.tsx b/apps/playground-web/src/app/layout.tsx index 4cb73e93b26..aafb518d396 100644 --- a/apps/playground-web/src/app/layout.tsx +++ b/apps/playground-web/src/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import { Fira_Code, Inter } from "next/font/google"; -import Script from "next/script"; import { metadataBase } from "@/lib/constants"; import { cn } from "@/lib/utils"; import { AppSidebar } from "./AppSidebar"; @@ -36,15 +35,6 @@ export default async function RootLayout({ const sidebarLinks = getSidebarLinks(); return ( - -