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/@/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/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/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)/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 && ( "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..02ad3c9aec2 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 @@ -3,7 +3,10 @@ import { notFound, redirect } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/projects"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { getSingleTransaction } from "../../lib/analytics"; +import { + getSingleTransaction, + getTransactionActivityLogs, +} from "../../lib/analytics"; import { TransactionDetailsUI } from "./transaction-details-ui"; export default async function TransactionPage({ @@ -26,11 +29,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, @@ -44,6 +54,7 @@ export default async function TransactionPage({ return (
- {`${transactionHash.slice(0, 8)}...${transactionHash.slice(-6)}`}{" "} + {`${transactionHash.slice( + 0, + 8, + )}...${transactionHash.slice(-6)}`}{" "} @@ -165,7 +183,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 +210,7 @@ export function TransactionDetailsUI({ client={client} src={chain.icon?.url} /> - {chain.name} + {chain.name || "Unknown"}
) : (
Chain ID: {chainId || "Unknown"}
@@ -347,7 +368,183 @@ export function TransactionDetailsUI({ )} + + {/* Activity Log Card */} + ); } + +// 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/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: {