Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781a626461 | ||
|
|
f630912b9b | ||
|
|
163ab8afe1 | ||
|
|
62cfbb7441 | ||
|
|
d7104f69f4 | ||
|
|
23545cb999 | ||
|
|
f9283f389b | ||
|
|
9fbb29dc25 | ||
|
|
71a1e58eaf | ||
|
|
68b48f4faa | ||
|
|
1c660d4712 | ||
|
|
30b5c30ba0 | ||
|
|
86461f9a07 | ||
|
|
2ebba6139a | ||
|
|
f3bacc16cb | ||
|
|
968913d55b | ||
|
|
bd70db4a5d | ||
|
|
d9a138a90a | ||
|
|
777aed1e5f | ||
|
|
fbed4be959 | ||
|
|
5a26217a43 | ||
|
|
938c1e2f60 | ||
|
|
af9a73d930 | ||
|
|
34c5d24043 | ||
|
|
e612f16a85 | ||
|
|
4a30b9c89a | ||
|
|
ef9cf3409b | ||
|
|
fd20a6cabb | ||
|
|
9095041ea7 | ||
|
|
2dbb9e2de4 | ||
|
|
00b8adf3a2 | ||
|
|
ae3dfa36c3 | ||
|
|
eb603f8908 | ||
|
|
e11b0da20e | ||
|
|
4c51fe6a8f | ||
|
|
64034c0ddb | ||
|
|
90c1e576ff | ||
|
|
27d5a81c49 | ||
|
|
dbadc23e04 | ||
|
|
21d5d0b6af | ||
|
|
b2239c91c9 | ||
|
|
ca701a48c2 | ||
|
|
94aae902a1 | ||
|
|
0c7149eb44 | ||
|
|
ce9220ea00 | ||
|
|
99c2af5e25 | ||
|
|
7495f46222 | ||
|
|
b8afa98de6 | ||
|
|
496615feae | ||
|
|
e2931df858 | ||
|
|
85bde21dc9 | ||
|
|
2c84ac4089 | ||
|
|
5ee449fc9c | ||
|
|
a94e9c2396 | ||
|
|
64f5e474bb | ||
|
|
aa0d23e966 | ||
|
|
b888413ca6 | ||
|
|
108a79ab3e | ||
|
|
51390f3af0 | ||
|
|
fb8c95a325 | ||
|
|
cf02cbf3af | ||
|
|
f152139d73 | ||
|
|
16307019be | ||
|
|
10fb338877 | ||
|
|
95412c75b9 | ||
|
|
7bc513c770 | ||
|
|
9069351e1a | ||
|
|
8f6363f4e4 | ||
|
|
a83104efff | ||
|
|
7936ebb6e9 | ||
|
|
3b986d52e5 | ||
|
|
ee4e13e098 | ||
|
|
8dc9e4102d | ||
|
|
33fc3e5226 | ||
|
|
ed47e93f1d | ||
|
|
1f7cda6698 | ||
|
|
c26fc676c9 | ||
|
|
e1cccd7a10 | ||
|
|
3646964f2e | ||
|
|
db145b5621 | ||
|
|
db3838032a | ||
|
|
60021b3a44 | ||
|
|
a81899d60a | ||
|
|
b366d56039 | ||
|
|
e568bcea60 | ||
|
|
a94f22928a | ||
|
|
5249cb7409 | ||
|
|
b37ed57365 | ||
|
|
c328aa13a6 | ||
|
|
a85ef0b222 | ||
|
|
51177fbe17 | ||
|
|
1338a0cf2c | ||
|
|
96ebf378ff | ||
|
|
b7732d3a75 | ||
|
|
6f88d38ad2 | ||
|
|
d53f8a25b4 | ||
|
|
dd693169bc | ||
|
|
93f913c614 | ||
|
|
02502159b6 | ||
|
|
e9225c3bad | ||
|
|
5967fb8afa | ||
|
|
fddb791f0a | ||
|
|
1732e63bb8 | ||
|
|
7b8ceebb63 | ||
|
|
1d90437d34 | ||
|
|
59616d0082 | ||
|
|
32980f854b | ||
|
|
f3b3b89fca | ||
|
|
44826925d0 | ||
|
|
00dcc346bf | ||
|
|
e0738d68d2 | ||
|
|
e160deb8b7 | ||
|
|
a6f846fc1e | ||
|
|
0e3f3460ad | ||
|
|
552f7025c1 | ||
|
|
15f083912c | ||
|
|
04862de9e4 | ||
|
|
ea46572947 | ||
|
|
f276dcfd76 | ||
|
|
6495b94910 | ||
|
|
210f631cba | ||
|
|
557f0e29e8 | ||
|
|
f9bbf8101c | ||
|
|
59d226512c | ||
|
|
778bf2cc4b | ||
|
|
97e5080fdf | ||
|
|
9f9fadf242 | ||
|
|
224c9d034f | ||
|
|
8b8dfd9bf9 | ||
|
|
70e52188a1 | ||
|
|
c82b606cd2 | ||
|
|
1c6b12449b | ||
|
|
ea8061048a | ||
|
|
3ad9f7deba | ||
|
|
0b454050b1 | ||
|
|
dc1f7ab2ee | ||
|
|
bd17c08891 | ||
|
|
79e3694e37 | ||
|
|
ef15ba7c43 | ||
|
|
a724bc39a2 | ||
|
|
2eaff98d8a | ||
|
|
1759112d72 | ||
|
|
85d6074e92 | ||
|
|
2f6f3a0947 |
@@ -2,7 +2,7 @@ name: Auto Author Assign
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review, draft]
|
||||
types: [opened, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -64,3 +64,5 @@ ANALYTICS_SERVICE_URL=""
|
||||
|
||||
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
|
||||
STRIPE_SECRET_KEY=""
|
||||
GROWTH_PLAN_SKU=""
|
||||
PAYMENT_METHOD_CONFIGURATION=""
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = [
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/payments",
|
||||
"/payments/x402",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@sentry/nextjs": "9.34.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@stripe/react-stripe-js": "4.0.2",
|
||||
"@stripe/stripe-js": "7.9.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/api": "workspace:*",
|
||||
@@ -45,6 +47,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"ogl": "^1.0.11",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
|
||||
@@ -474,6 +474,11 @@ async function redirects() {
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/routes",
|
||||
destination: "/tokens",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
|
||||
interface VaultWalletListItem {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata?: {
|
||||
label?: string;
|
||||
projectId?: string;
|
||||
teamId?: string;
|
||||
type?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function listProjectServerWallets(params: {
|
||||
managementAccessToken: string;
|
||||
projectId: string;
|
||||
pageSize?: number;
|
||||
}): Promise<ProjectWalletSummary[]> {
|
||||
const { managementAccessToken, projectId, pageSize = 100 } = params;
|
||||
|
||||
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: 0,
|
||||
// @ts-expect-error - Vault SDK expects snake_case pagination fields
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data?.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = response.data.items as VaultWalletListItem[];
|
||||
|
||||
return items
|
||||
.filter((item) => {
|
||||
return (
|
||||
item.metadata?.projectId === projectId &&
|
||||
(!item.metadata?.type || item.metadata.type === "server-wallet")
|
||||
);
|
||||
})
|
||||
.map<ProjectWalletSummary>((item) => ({
|
||||
id: item.id,
|
||||
address: item.address,
|
||||
label: item.metadata?.label ?? undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to list project server wallets", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use server";
|
||||
|
||||
import { configure, sendTokens } from "@thirdweb-dev/api";
|
||||
import { THIRDWEB_API_HOST } from "@/constants/urls";
|
||||
|
||||
configure({
|
||||
override: {
|
||||
baseUrl: THIRDWEB_API_HOST,
|
||||
},
|
||||
});
|
||||
|
||||
export async function sendProjectWalletTokens(options: {
|
||||
walletAddress: string;
|
||||
recipientAddress: string;
|
||||
chainId: number;
|
||||
quantityWei: string;
|
||||
publishableKey: string;
|
||||
teamId: string;
|
||||
tokenAddress?: string;
|
||||
secretKey: string;
|
||||
vaultAccessToken?: string;
|
||||
}) {
|
||||
const {
|
||||
walletAddress,
|
||||
recipientAddress,
|
||||
chainId,
|
||||
quantityWei,
|
||||
publishableKey,
|
||||
teamId,
|
||||
tokenAddress,
|
||||
secretKey,
|
||||
vaultAccessToken,
|
||||
} = options;
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
error: "A project secret key is required to send funds.",
|
||||
ok: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const response = await sendTokens({
|
||||
body: {
|
||||
chainId,
|
||||
from: walletAddress,
|
||||
recipients: [
|
||||
{
|
||||
address: recipientAddress,
|
||||
quantity: quantityWei,
|
||||
},
|
||||
],
|
||||
...(tokenAddress ? { tokenAddress } : {}),
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": publishableKey,
|
||||
"x-secret-key": secretKey,
|
||||
"x-team-id": teamId,
|
||||
...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
return {
|
||||
error: response.error || "Failed to submit transfer request.",
|
||||
ok: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
transactionIds: response.data.result?.transactionIds ?? [],
|
||||
} as const;
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import "server-only";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import Stripe from "stripe";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
|
||||
import {
|
||||
GROWTH_PLAN_SKU,
|
||||
PAYMENT_METHOD_CONFIGURATION,
|
||||
STRIPE_SECRET_KEY,
|
||||
} from "@/constants/server-envs";
|
||||
|
||||
let existingStripe: Stripe | undefined;
|
||||
|
||||
@@ -72,3 +77,64 @@ export async function getStripeBalance(customerId: string) {
|
||||
// Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0)
|
||||
return customer.balance === 0 ? 0 : customer.balance / -100;
|
||||
}
|
||||
|
||||
export async function fetchClientSecret(team: Team) {
|
||||
"use server";
|
||||
const origin = (await headers()).get("origin");
|
||||
const stripe = getStripe();
|
||||
const customerId = team.stripeCustomerId;
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error("No customer ID found");
|
||||
}
|
||||
|
||||
// try to get the gclid cookie
|
||||
const gclid = (await cookies()).get("gclid")?.value;
|
||||
|
||||
// Create Checkout Sessions from body params.
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
ui_mode: "embedded",
|
||||
line_items: [
|
||||
{
|
||||
// Provide the exact Price ID (for example, price_1234) of
|
||||
// the product you want to sell
|
||||
price: GROWTH_PLAN_SKU,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
|
||||
return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
|
||||
automatic_tax: { enabled: true },
|
||||
allow_promotion_codes: true,
|
||||
customer: customerId,
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
},
|
||||
payment_method_collection: "always",
|
||||
payment_method_configuration: PAYMENT_METHOD_CONFIGURATION,
|
||||
subscription_data: {
|
||||
trial_period_days: 14,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
missing_payment_method: "cancel",
|
||||
},
|
||||
},
|
||||
// if gclid exists, set it as a metadata field so we can attribute the conversion later
|
||||
metadata: gclid ? { gclid } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.client_secret) {
|
||||
throw new Error("No client secret found");
|
||||
}
|
||||
|
||||
return session.client_secret;
|
||||
}
|
||||
|
||||
export async function getStripeSessionById(sessionId: string) {
|
||||
const session = await getStripe().checkout.sessions.retrieve(sessionId, {
|
||||
expand: ["line_items", "payment_intent"],
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function reportContractDeployed(properties: {
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
deploymentType?: "asset";
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("contract deployed", properties);
|
||||
}
|
||||
@@ -40,6 +41,7 @@ export function reportContractDeployed(properties: {
|
||||
export function reportContractDeployFailed(properties: {
|
||||
errorMessage: string;
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
}) {
|
||||
@@ -81,33 +83,6 @@ export function reportOnboardingStarted() {
|
||||
posthog.capture("onboarding started");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that select a paid plan during onboarding
|
||||
* - To know **which** plan was selected
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingPlanSelected(properties: {
|
||||
plan: Team["billingPlan"];
|
||||
}) {
|
||||
posthog.capture("onboarding plan selected", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that skip the plan-selection step during onboarding
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingPlanSelectionSkipped() {
|
||||
posthog.capture("onboarding plan selection skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that invite members during onboarding
|
||||
@@ -161,6 +136,18 @@ export function reportOnboardingMembersUpsellPlanSelected(properties: {
|
||||
posthog.capture("onboarding members upsell plan selected", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that completed the team member step during onboarding
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportTeamMemberStepCompleted() {
|
||||
posthog.capture("onboarding members completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that completed onboarding
|
||||
@@ -186,9 +173,7 @@ export function reportOnboardingCompleted() {
|
||||
*
|
||||
*/
|
||||
export function reportFaucetUsed(properties: { chainId: number }) {
|
||||
posthog.capture("faucet used", {
|
||||
chainId: properties.chainId,
|
||||
});
|
||||
posthog.capture("faucet used", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -212,12 +197,7 @@ export function reportChainConfigurationAdded(properties: {
|
||||
decimals: number;
|
||||
};
|
||||
}) {
|
||||
posthog.capture("chain configuration added", {
|
||||
chainId: properties.chainId,
|
||||
chainName: properties.chainName,
|
||||
nativeCurrency: properties.nativeCurrency,
|
||||
rpcURLs: properties.rpcURLs,
|
||||
});
|
||||
posthog.capture("chain configuration added", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -240,19 +220,120 @@ type AssetContractType =
|
||||
*/
|
||||
export function reportAssetBuySuccessful(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset buy successful", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset buy successful", properties);
|
||||
}
|
||||
|
||||
type TokenSwapParams = {
|
||||
buyTokenChainId: number;
|
||||
buyTokenAddress: string;
|
||||
sellTokenChainId: number;
|
||||
sellTokenAddress: string;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
};
|
||||
|
||||
type TokenBuyParams = {
|
||||
buyTokenChainId: number | undefined;
|
||||
buyTokenAddress: string | undefined;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
};
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of successful token buys
|
||||
* - To track which tokens are being bought the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenBuySuccessful(properties: TokenBuyParams) {
|
||||
posthog.capture("token buy successful", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed asset purchases from the token page
|
||||
* - To track number of failed token buys
|
||||
* - To track which token buys are failing
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenBuyFailed(properties: TokenBuyParams) {
|
||||
posthog.capture("token buy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of cancelled token buys
|
||||
* - To track which token buys are being cancelled
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenBuyCancelled(properties: TokenBuyParams) {
|
||||
posthog.capture("token buy cancelled", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of successful token swaps
|
||||
* - To track which tokens are being swapped the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
|
||||
posthog.capture("token swap successful", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track impressions of the swap widget
|
||||
* - To create a funnel "swap widget shown" -> "swap widget successful" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportSwapWidgetShown(properties: {
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
}) {
|
||||
posthog.capture("swap widget shown", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed token swaps
|
||||
* - To track which tokens are being swapped the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenSwapFailed(
|
||||
properties: TokenSwapParams & {
|
||||
errorMessage: string;
|
||||
},
|
||||
) {
|
||||
posthog.capture("token swap failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of cancelled token swaps
|
||||
* - To track which tokens are being swapped the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenSwapCancelled(properties: TokenSwapParams) {
|
||||
posthog.capture("token swap cancelled", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed asset purchases
|
||||
* - To track the errors that users encounter when trying to purchase an asset
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
@@ -260,16 +341,29 @@ export function reportAssetBuySuccessful(properties: {
|
||||
*/
|
||||
export function reportAssetBuyFailed(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
is_testnet: boolean | undefined;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
error: string;
|
||||
}) {
|
||||
posthog.capture("asset buy failed", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
});
|
||||
posthog.capture("asset buy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of cancelled asset purchases from the token page
|
||||
* - To track the errors that users encounter when trying to purchase an asset
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetBuyCancelled(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy cancelled", properties);
|
||||
}
|
||||
|
||||
// Assets Landing Page ----------------------------
|
||||
@@ -314,10 +408,7 @@ export function reportAssetCreationStepConfigured(
|
||||
step: "coin-info" | "token-distribution" | "launch-coin";
|
||||
},
|
||||
) {
|
||||
posthog.capture("asset creation step configured", {
|
||||
assetType: properties.assetType,
|
||||
step: properties.step,
|
||||
});
|
||||
posthog.capture("asset creation step configured", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,11 +422,10 @@ export function reportAssetCreationStepConfigured(
|
||||
export function reportAssetCreationSuccessful(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
contractType: AssetContractType;
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset creation successful", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset creation successful", properties);
|
||||
}
|
||||
|
||||
type CoinCreationStep =
|
||||
@@ -357,7 +447,12 @@ type CoinCreationStep =
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationFailed(
|
||||
properties: { contractType: AssetContractType; error: string } & (
|
||||
properties: {
|
||||
contractType: AssetContractType;
|
||||
error: string;
|
||||
is_testnet: boolean | undefined;
|
||||
chainId: number;
|
||||
} & (
|
||||
| {
|
||||
assetType: "nft";
|
||||
step:
|
||||
@@ -372,12 +467,7 @@ export function reportAssetCreationFailed(
|
||||
}
|
||||
),
|
||||
) {
|
||||
posthog.capture("asset creation failed", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
step: properties.step,
|
||||
});
|
||||
posthog.capture("asset creation failed", properties);
|
||||
}
|
||||
|
||||
type UpsellParams = {
|
||||
@@ -473,10 +563,25 @@ export function reportPaymentLinkBuyFailed(properties: {
|
||||
export function reportAssetPageview(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset pageview", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To understand which chains are being viewed the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportChainPageview(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("chain pageview", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the usage of fund wallet modal
|
||||
@@ -547,3 +652,38 @@ export function reportChainInfraRpcOmissionAgreed(properties: {
|
||||
}) {
|
||||
posthog.capture("chain infra checkout rpc omission agreed", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// FEEDBACK
|
||||
// ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track user feedback and sentiment about the product
|
||||
* - To identify common issues or feature requests
|
||||
* - To measure user satisfaction and engagement
|
||||
* - To prioritize product improvements based on user input
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @gisellechacon
|
||||
*/
|
||||
export function reportProductFeedback(properties: {
|
||||
feedback: string;
|
||||
source: "desktop" | "mobile";
|
||||
}) {
|
||||
posthog.capture("product feedback submitted", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track conversions for the bridge page links
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*
|
||||
*/
|
||||
export function reportBridgePageLinkClick(params: {
|
||||
linkType: "integrate-bridge" | "trending-tokens";
|
||||
}) {
|
||||
posthog.capture("bridge page link clicked", params);
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ type ActiveStatus = {
|
||||
pay: boolean;
|
||||
inAppWallet: boolean;
|
||||
ecosystemWallet: boolean;
|
||||
engineCloud: boolean;
|
||||
};
|
||||
|
||||
export const isProjectActive = unstable_cache(
|
||||
@@ -479,6 +480,7 @@ export const isProjectActive = unstable_cache(
|
||||
pay: false,
|
||||
rpc: false,
|
||||
sdk: false,
|
||||
engineCloud: false,
|
||||
storage: false,
|
||||
} as ActiveStatus;
|
||||
}
|
||||
@@ -488,7 +490,7 @@ export const isProjectActive = unstable_cache(
|
||||
},
|
||||
["isProjectActive"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
revalidate: 30, // 30 seconds
|
||||
},
|
||||
);
|
||||
|
||||
@@ -592,7 +594,7 @@ const cached_getUniversalBridgeUsage = unstable_cache(
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
`Failed to fetch bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -636,7 +638,7 @@ const cached_getUniversalBridgeWalletUsage = unstable_cache(
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch universal bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
`Failed to fetch bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { Mesh, Program, Renderer, Triangle, Vec2 } from "ogl";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const vertex = `
|
||||
attribute vec2 position;
|
||||
void main(){gl_Position=vec4(position,0.0,1.0);}
|
||||
`;
|
||||
|
||||
const fragment = `
|
||||
#ifdef GL_ES
|
||||
precision lowp float;
|
||||
#endif
|
||||
uniform vec2 uResolution;
|
||||
uniform float uTime;
|
||||
uniform float uHueShift;
|
||||
uniform float uNoise;
|
||||
uniform float uScan;
|
||||
uniform float uScanFreq;
|
||||
uniform float uWarp;
|
||||
uniform vec3 uBackgroundColor;
|
||||
uniform float uBackgroundOpacity;
|
||||
uniform float uPatternLightness;
|
||||
#define iTime uTime
|
||||
#define iResolution uResolution
|
||||
|
||||
vec4 buf[8];
|
||||
float rand(vec2 c){return fract(sin(dot(c,vec2(12.9898,78.233)))*43758.5453);}
|
||||
|
||||
mat3 rgb2yiq=mat3(0.299,0.587,0.114,0.596,-0.274,-0.322,0.211,-0.523,0.312);
|
||||
mat3 yiq2rgb=mat3(1.0,0.956,0.621,1.0,-0.272,-0.647,1.0,-1.106,1.703);
|
||||
|
||||
vec3 hueShiftRGB(vec3 col,float deg){
|
||||
vec3 yiq=rgb2yiq*col;
|
||||
float rad=radians(deg);
|
||||
float cosh=cos(rad),sinh=sin(rad);
|
||||
vec3 yiqShift=vec3(yiq.x,yiq.y*cosh-yiq.z*sinh,yiq.y*sinh+yiq.z*cosh);
|
||||
return clamp(yiq2rgb*yiqShift,0.0,1.0);
|
||||
}
|
||||
|
||||
vec4 sigmoid(vec4 x){return 1./(1.+exp(-x));}
|
||||
|
||||
vec4 cppn_fn(vec2 coordinate,float in0,float in1,float in2){
|
||||
buf[6]=vec4(coordinate.x,coordinate.y,0.3948333106474662+in0,0.36+in1);
|
||||
buf[7]=vec4(0.14+in2,sqrt(coordinate.x*coordinate.x+coordinate.y*coordinate.y),0.,0.);
|
||||
buf[0]=mat4(vec4(6.5404263,-3.6126034,0.7590882,-1.13613),vec4(2.4582713,3.1660357,1.2219609,0.06276096),vec4(-5.478085,-6.159632,1.8701609,-4.7742867),vec4(6.039214,-5.542865,-0.90925294,3.251348))*buf[6]+mat4(vec4(0.8473259,-5.722911,3.975766,1.6522468),vec4(-0.24321538,0.5839259,-1.7661959,-5.350116),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(0.21808943,1.1243913,-1.7969975,5.0294676);
|
||||
buf[1]=mat4(vec4(-3.3522482,-6.0612736,0.55641043,-4.4719114),vec4(0.8631464,1.7432913,5.643898,1.6106541),vec4(2.4941394,-3.5012043,1.7184316,6.357333),vec4(3.310376,8.209261,1.1355612,-1.165539))*buf[6]+mat4(vec4(5.24046,-13.034365,0.009859298,15.870829),vec4(2.987511,3.129433,-0.89023495,-1.6822904),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-5.9457836,-6.573602,-0.8812491,1.5436668);
|
||||
buf[0]=sigmoid(buf[0]);buf[1]=sigmoid(buf[1]);
|
||||
buf[2]=mat4(vec4(-15.219568,8.095543,-2.429353,-1.9381982),vec4(-5.951362,4.3115187,2.6393783,1.274315),vec4(-7.3145227,6.7297835,5.2473326,5.9411426),vec4(5.0796127,8.979051,-1.7278991,-1.158976))*buf[6]+mat4(vec4(-11.967154,-11.608155,6.1486754,11.237008),vec4(2.124141,-6.263192,-1.7050359,-0.7021966),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-4.17164,-3.2281182,-4.576417,-3.6401186);
|
||||
buf[3]=mat4(vec4(3.1832156,-13.738922,1.879223,3.233465),vec4(0.64300746,12.768129,1.9141049,0.50990224),vec4(-0.049295485,4.4807224,1.4733979,1.801449),vec4(5.0039253,13.000481,3.3991797,-4.5561905))*buf[6]+mat4(vec4(-0.1285731,7.720628,-3.1425676,4.742367),vec4(0.6393625,3.714393,-0.8108378,-0.39174938),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-1.1811101,-21.621881,0.7851888,1.2329718);
|
||||
buf[2]=sigmoid(buf[2]);buf[3]=sigmoid(buf[3]);
|
||||
buf[4]=mat4(vec4(5.214916,-7.183024,2.7228765,2.6592617),vec4(-5.601878,-25.3591,4.067988,0.4602802),vec4(-10.57759,24.286327,21.102104,37.546658),vec4(4.3024497,-1.9625226,2.3458803,-1.372816))*buf[0]+mat4(vec4(-17.6526,-10.507558,2.2587414,12.462782),vec4(6.265566,-502.75443,-12.642513,0.9112289),vec4(-10.983244,20.741234,-9.701768,-0.7635988),vec4(5.383626,1.4819539,-4.1911616,-4.8444734))*buf[1]+mat4(vec4(12.785233,-16.345072,-0.39901125,1.7955981),vec4(-30.48365,-1.8345358,1.4542528,-1.1118771),vec4(19.872723,-7.337935,-42.941723,-98.52709),vec4(8.337645,-2.7312303,-2.2927687,-36.142323))*buf[2]+mat4(vec4(-16.298317,3.5471997,-0.44300047,-9.444417),vec4(57.5077,-35.609753,16.163465,-4.1534753),vec4(-0.07470326,-3.8656476,-7.0901804,3.1523974),vec4(-12.559385,-7.077619,1.490437,-0.8211543))*buf[3]+vec4(-7.67914,15.927437,1.3207729,-1.6686112);
|
||||
buf[5]=mat4(vec4(-1.4109162,-0.372762,-3.770383,-21.367174),vec4(-6.2103205,-9.35908,0.92529047,8.82561),vec4(11.460242,-22.348068,13.625772,-18.693201),vec4(-0.3429052,-3.9905605,-2.4626114,-0.45033523))*buf[0]+mat4(vec4(7.3481627,-4.3661838,-6.3037653,-3.868115),vec4(1.5462853,6.5488915,1.9701879,-0.58291394),vec4(6.5858274,-2.2180402,3.7127688,-1.3730392),vec4(-5.7973905,10.134961,-2.3395722,-5.965605))*buf[1]+mat4(vec4(-2.5132585,-6.6685553,-1.4029363,-0.16285264),vec4(-0.37908727,0.53738135,4.389061,-1.3024765),vec4(-0.70647055,2.0111287,-5.1659346,-3.728635),vec4(-13.562562,10.487719,-0.9173751,-2.6487076))*buf[2]+mat4(vec4(-8.645013,6.5546675,-6.3944063,-5.5933375),vec4(-0.57783127,-1.077275,36.91025,5.736769),vec4(14.283112,3.7146652,7.1452246,-4.5958776),vec4(2.7192075,3.6021907,-4.366337,-2.3653464))*buf[3]+vec4(-5.9000807,-4.329569,1.2427121,8.59503);
|
||||
buf[4]=sigmoid(buf[4]);buf[5]=sigmoid(buf[5]);
|
||||
buf[6]=mat4(vec4(-1.61102,0.7970257,1.4675229,0.20917463),vec4(-28.793737,-7.1390953,1.5025433,4.656581),vec4(-10.94861,39.66238,0.74318546,-10.095605),vec4(-0.7229728,-1.5483948,0.7301322,2.1687684))*buf[0]+mat4(vec4(3.2547753,21.489103,-1.0194173,-3.3100595),vec4(-3.7316632,-3.3792162,-7.223193,-0.23685838),vec4(13.1804495,0.7916005,5.338587,5.687114),vec4(-4.167605,-17.798311,-6.815736,-1.6451967))*buf[1]+mat4(vec4(0.604885,-7.800309,-7.213122,-2.741014),vec4(-3.522382,-0.12359311,-0.5258442,0.43852118),vec4(9.6752825,-22.853785,2.062431,0.099892326),vec4(-4.3196306,-17.730087,2.5184598,5.30267))*buf[2]+mat4(vec4(-6.545563,-15.790176,-6.0438633,-5.415399),vec4(-43.591583,28.551912,-16.00161,18.84728),vec4(4.212382,8.394307,3.0958717,8.657522),vec4(-5.0237565,-4.450633,-4.4768,-5.5010443))*buf[3]+mat4(vec4(1.6985557,-67.05806,6.897715,1.9004834),vec4(1.8680354,2.3915145,2.5231109,4.081538),vec4(11.158006,1.7294737,2.0738268,7.386411),vec4(-4.256034,-306.24686,8.258898,-17.132736))*buf[4]+mat4(vec4(1.6889864,-4.5852966,3.8534803,-6.3482175),vec4(1.3543309,-1.2640043,9.932754,2.9079645),vec4(-5.2770967,0.07150358,-0.13962056,3.3269649),vec4(28.34703,-4.918278,6.1044083,4.085355))*buf[5]+vec4(6.6818056,12.522166,-3.7075126,-4.104386);
|
||||
buf[7]=mat4(vec4(-8.265602,-4.7027016,5.098234,0.7509808),vec4(8.6507845,-17.15949,16.51939,-8.884479),vec4(-4.036479,-2.3946867,-2.6055532,-1.9866527),vec4(-2.2167742,-1.8135649,-5.9759874,4.8846445))*buf[0]+mat4(vec4(6.7790847,3.5076547,-2.8191125,-2.7028968),vec4(-5.743024,-0.27844876,1.4958696,-5.0517144),vec4(13.122226,15.735168,-2.9397483,-4.101023),vec4(-14.375265,-5.030483,-6.2599335,2.9848232))*buf[1]+mat4(vec4(4.0950394,-0.94011575,-5.674733,4.755022),vec4(4.3809423,4.8310084,1.7425908,-3.437416),vec4(2.117492,0.16342592,-104.56341,16.949184),vec4(-5.22543,-2.994248,3.8350096,-1.9364246))*buf[2]+mat4(vec4(-5.900337,1.7946124,-13.604192,-3.8060522),vec4(6.6583457,31.911177,25.164474,91.81147),vec4(11.840538,4.1503043,-0.7314397,6.768467),vec4(-6.3967767,4.034772,6.1714606,-0.32874924))*buf[3]+mat4(vec4(3.4992442,-196.91893,-8.923708,2.8142626),vec4(3.4806502,-3.1846354,5.1725626,5.1804223),vec4(-2.4009497,15.585794,1.2863957,2.0252278),vec4(-71.25271,-62.441242,-8.138444,0.50670296))*buf[4]+mat4(vec4(-12.291733,-11.176166,-7.3474145,4.390294),vec4(10.805477,5.6337385,-0.9385842,-4.7348723),vec4(-12.869276,-7.039391,5.3029537,7.5436664),vec4(1.4593618,8.91898,3.5101583,5.840625))*buf[5]+vec4(2.2415268,-6.705987,-0.98861027,-2.117676);
|
||||
buf[6]=sigmoid(buf[6]);buf[7]=sigmoid(buf[7]);
|
||||
buf[0]=mat4(vec4(1.6794263,1.3817469,2.9625452,0.),vec4(-1.8834411,-1.4806935,-3.5924516,0.),vec4(-1.3279216,-1.0918057,-2.3124623,0.),vec4(0.2662234,0.23235129,0.44178495,0.))*buf[0]+mat4(vec4(-0.6299101,-0.5945583,-0.9125601,0.),vec4(0.17828953,0.18300213,0.18182953,0.),vec4(-2.96544,-2.5819945,-4.9001055,0.),vec4(1.4195864,1.1868085,2.5176322,0.))*buf[1]+mat4(vec4(-1.2584374,-1.0552157,-2.1688404,0.),vec4(-0.7200217,-0.52666044,-1.438251,0.),vec4(0.15345335,0.15196142,0.272854,0.),vec4(0.945728,0.8861938,1.2766753,0.))*buf[2]+mat4(vec4(-2.4218085,-1.968602,-4.35166,0.),vec4(-22.683098,-18.0544,-41.954372,0.),vec4(0.63792,0.5470648,1.1078634,0.),vec4(-1.5489894,-1.3075932,-2.6444845,0.))*buf[3]+mat4(vec4(-0.49252132,-0.39877754,-0.91366625,0.),vec4(0.95609266,0.7923952,1.640221,0.),vec4(0.30616966,0.15693925,0.8639857,0.),vec4(1.1825981,0.94504964,2.176963,0.))*buf[4]+mat4(vec4(0.35446745,0.3293795,0.59547555,0.),vec4(-0.58784515,-0.48177817,-1.0614829,0.),vec4(2.5271258,1.9991658,4.6846647,0.),vec4(0.13042648,0.08864098,0.30187556,0.))*buf[5]+mat4(vec4(-1.7718065,-1.4033192,-3.3355875,0.),vec4(3.1664357,2.638297,5.378702,0.),vec4(-3.1724713,-2.6107926,-5.549295,0.),vec4(-2.851368,-2.249092,-5.3013067,0.))*buf[6]+mat4(vec4(1.5203838,1.2212278,2.8404984,0.),vec4(1.5210563,1.2651345,2.683903,0.),vec4(2.9789467,2.4364579,5.2347264,0.),vec4(2.2270417,1.8825914,3.8028636,0.))*buf[7]+vec4(-1.5468478,-3.6171484,0.24762098,0.);
|
||||
buf[0]=sigmoid(buf[0]);
|
||||
return vec4(buf[0].x,buf[0].y,buf[0].z,1.);
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor,in vec2 fragCoord){
|
||||
vec2 uv=fragCoord/uResolution.xy*2.-1.;
|
||||
uv.y*=-1.;
|
||||
uv+=uWarp*vec2(sin(uv.y*6.283+uTime*0.5),cos(uv.x*6.283+uTime*0.5))*0.05;
|
||||
fragColor=cppn_fn(uv,0.1*sin(0.3*uTime),0.1*sin(0.69*uTime),0.1*sin(0.44*uTime));
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec4 col;mainImage(col,gl_FragCoord.xy);
|
||||
col.rgb=hueShiftRGB(col.rgb,uHueShift);
|
||||
float scanline_val=sin(gl_FragCoord.y*uScanFreq)*0.5+0.5;
|
||||
col.rgb*=1.-(scanline_val*scanline_val)*uScan;
|
||||
col.rgb+=(rand(gl_FragCoord.xy+uTime)-0.5)*uNoise;
|
||||
|
||||
// Apply lightness control to pattern
|
||||
col.rgb = col.rgb * uPatternLightness;
|
||||
|
||||
// Use background color as base, then add pattern on top
|
||||
vec3 finalColor = uBackgroundColor + col.rgb * uBackgroundOpacity;
|
||||
|
||||
gl_FragColor=vec4(clamp(finalColor,0.0,1.0),1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
hueShift?: number;
|
||||
noiseIntensity?: number;
|
||||
scanlineIntensity?: number;
|
||||
speed?: number;
|
||||
scanlineFrequency?: number;
|
||||
warpAmount?: number;
|
||||
resolutionScale?: number;
|
||||
backgroundColor?: [number, number, number];
|
||||
backgroundOpacity?: number;
|
||||
patternLightness?: number;
|
||||
};
|
||||
|
||||
export function DarkVeil({
|
||||
hueShift = 0,
|
||||
noiseIntensity = 0,
|
||||
scanlineIntensity = 0,
|
||||
speed = 0.5,
|
||||
scanlineFrequency = 0,
|
||||
warpAmount = 0,
|
||||
resolutionScale = 1,
|
||||
backgroundColor = [0, 0, 0],
|
||||
backgroundOpacity = 1,
|
||||
patternLightness = 1.0,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current as HTMLCanvasElement;
|
||||
const parent = canvas.parentElement as HTMLElement;
|
||||
|
||||
const renderer = new Renderer({
|
||||
dpr: Math.min(window.devicePixelRatio, 2),
|
||||
canvas,
|
||||
});
|
||||
|
||||
const gl = renderer.gl;
|
||||
const geometry = new Triangle(gl);
|
||||
|
||||
const program = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uResolution: { value: new Vec2() },
|
||||
uHueShift: { value: hueShift },
|
||||
uNoise: { value: noiseIntensity },
|
||||
uScan: { value: scanlineIntensity },
|
||||
uScanFreq: { value: scanlineFrequency },
|
||||
uWarp: { value: warpAmount },
|
||||
uBackgroundColor: { value: backgroundColor },
|
||||
uBackgroundOpacity: { value: backgroundOpacity },
|
||||
uPatternLightness: { value: patternLightness },
|
||||
},
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
const resize = () => {
|
||||
const w = parent.clientWidth,
|
||||
h = parent.clientHeight;
|
||||
renderer.setSize(w * resolutionScale, h * resolutionScale);
|
||||
program.uniforms.uResolution.value.set(w, h);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
resize();
|
||||
|
||||
const start = performance.now();
|
||||
let frame = 0;
|
||||
|
||||
const loop = () => {
|
||||
program.uniforms.uTime.value =
|
||||
((performance.now() - start) / 1000) * speed;
|
||||
program.uniforms.uHueShift.value = hueShift;
|
||||
program.uniforms.uNoise.value = noiseIntensity;
|
||||
program.uniforms.uScan.value = scanlineIntensity;
|
||||
program.uniforms.uScanFreq.value = scanlineFrequency;
|
||||
program.uniforms.uWarp.value = warpAmount;
|
||||
program.uniforms.uBackgroundColor.value = backgroundColor;
|
||||
program.uniforms.uBackgroundOpacity.value = backgroundOpacity;
|
||||
program.uniforms.uPatternLightness.value = patternLightness;
|
||||
renderer.render({ scene: mesh });
|
||||
frame = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
loop();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [
|
||||
hueShift,
|
||||
noiseIntensity,
|
||||
scanlineIntensity,
|
||||
speed,
|
||||
scanlineFrequency,
|
||||
warpAmount,
|
||||
resolutionScale,
|
||||
backgroundColor,
|
||||
backgroundOpacity,
|
||||
patternLightness,
|
||||
]);
|
||||
return <canvas ref={ref} className="w-full h-full block bg-background" />;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Chain } from "thirdweb";
|
||||
import { BuyWidget, SwapWidget } from "thirdweb/react";
|
||||
import {
|
||||
reportAssetBuyCancelled,
|
||||
reportAssetBuyFailed,
|
||||
reportAssetBuySuccessful,
|
||||
reportSwapWidgetShown,
|
||||
reportTokenBuyCancelled,
|
||||
reportTokenBuyFailed,
|
||||
reportTokenBuySuccessful,
|
||||
reportTokenSwapCancelled,
|
||||
reportTokenSwapFailed,
|
||||
reportTokenSwapSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
|
||||
} from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
|
||||
|
||||
type PageType = "asset" | "bridge" | "chain";
|
||||
|
||||
export function BuyAndSwapEmbed(props: {
|
||||
chain: Chain;
|
||||
tokenAddress: string | undefined;
|
||||
buyAmount: string | undefined;
|
||||
pageType: PageType;
|
||||
isTestnet: boolean | undefined;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [tab, setTab] = useState<"buy" | "swap">("swap");
|
||||
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
|
||||
const isMounted = useRef(false);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (isMounted.current) {
|
||||
return;
|
||||
}
|
||||
isMounted.current = true;
|
||||
reportSwapWidgetShown({
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}, [props.pageType]);
|
||||
|
||||
const client = useMemo(() => {
|
||||
return getConfiguredThirdwebClient({
|
||||
clientId:
|
||||
props.pageType === "asset"
|
||||
? NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID
|
||||
: props.pageType === "bridge"
|
||||
? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
|
||||
: props.pageType === "chain"
|
||||
? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID
|
||||
: undefined,
|
||||
secretKey: undefined,
|
||||
teamId: undefined,
|
||||
});
|
||||
}, [props.pageType]);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl min-w-0">
|
||||
<div className="flex gap-2.5 p-4 border-b border-dashed">
|
||||
<TabButton
|
||||
label="Swap"
|
||||
onClick={() => setTab("swap")}
|
||||
isActive={tab === "swap"}
|
||||
/>
|
||||
<TabButton
|
||||
label="Buy"
|
||||
onClick={() => setTab("buy")}
|
||||
isActive={tab === "buy"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === "buy" && (
|
||||
<BuyWidget
|
||||
amount={props.buyAmount || "1"}
|
||||
chain={props.chain}
|
||||
className="!rounded-2xl !border-none"
|
||||
title=""
|
||||
client={client}
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
}}
|
||||
onError={(e, quote) => {
|
||||
const errorMessage = parseError(e);
|
||||
|
||||
reportTokenBuyFailed({
|
||||
buyTokenChainId:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={(quote) => {
|
||||
reportTokenBuyCancelled({
|
||||
buyTokenChainId:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyCancelled({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSuccess={(quote) => {
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId:
|
||||
quote.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenAddress:
|
||||
quote.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
theme={themeObj}
|
||||
tokenAddress={props.tokenAddress as `0x${string}`}
|
||||
paymentMethods={["card"]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "swap" && (
|
||||
<SwapWidget
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none"
|
||||
prefill={{
|
||||
// buy this token by default
|
||||
buyToken: {
|
||||
chainId: props.chain.id,
|
||||
tokenAddress: props.tokenAddress,
|
||||
},
|
||||
// sell the native token by default (but if buytoken is a native token, don't set)
|
||||
sellToken: props.tokenAddress
|
||||
? {
|
||||
chainId: props.chain.id,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
onError={(error, quote) => {
|
||||
const errorMessage = parseError(error);
|
||||
reportTokenSwapFailed({
|
||||
errorMessage: errorMessage,
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onSuccess={(quote) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onCancel={(quote) => {
|
||||
reportTokenSwapCancelled({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={props.onClick}
|
||||
className={cn(
|
||||
"rounded-full text-muted-foreground px-5 text-base bg-accent",
|
||||
props.isActive && "text-foreground border-foreground",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Bridge, ThirdwebClient } from "thirdweb";
|
||||
import { MultiSelect } from "@/components/blocks/multi-select";
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -296,3 +296,89 @@ export function SingleNetworkSelector(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type BridgeNetworkSelectorProps = {
|
||||
chainId: number | undefined;
|
||||
onChange: (chainId: number) => void;
|
||||
className?: string;
|
||||
popoverContentClassName?: string;
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
align?: "center" | "start" | "end";
|
||||
placeholder?: string;
|
||||
client: ThirdwebClient;
|
||||
chains: Bridge.chains.Result;
|
||||
};
|
||||
|
||||
export function BridgeNetworkSelector(props: BridgeNetworkSelectorProps) {
|
||||
const options = useMemo(() => {
|
||||
return props.chains.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [props.chains]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const chain = props.chains.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
},
|
||||
[props.chains],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const chain = props.chains.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return option.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
src={chain.icon}
|
||||
loading="lazy"
|
||||
/>
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[props.chains, props.client],
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
align={props.align}
|
||||
className={props.className}
|
||||
closeOnSelect={true}
|
||||
onValueChange={(chainId) => {
|
||||
props.onChange(Number(chainId));
|
||||
}}
|
||||
options={options}
|
||||
overrideSearchFn={searchFn}
|
||||
placeholder={props.placeholder || "Select Chain"}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
renderOption={renderOption}
|
||||
searchPlaceholder="Search by Name or Chain ID"
|
||||
showCheck={false}
|
||||
side={props.side}
|
||||
value={props.chainId?.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
headerClassName?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
@@ -70,6 +71,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
<Card className={props.className}>
|
||||
{props.header && (
|
||||
<CardHeader className={props.header.headerClassName}>
|
||||
{props.header.icon}
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function ToggleThemeButton() {
|
||||
export function ToggleThemeButton(props: { className?: string }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
@@ -15,7 +16,10 @@ export function ToggleThemeButton() {
|
||||
>
|
||||
<Button
|
||||
aria-label="Toggle theme"
|
||||
className="h-auto w-auto rounded-full border border-border bg-background p-2"
|
||||
className={cn(
|
||||
"h-auto w-auto rounded-full border border-border bg-background p-2",
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FaqSection(props: {
|
||||
faqs: Array<{ title: string; description: string }>;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className="flex flex-col">
|
||||
{props.faqs.map((faq, faqIndex) => (
|
||||
<FaqItem
|
||||
key={faq.title}
|
||||
title={faq.title}
|
||||
description={faq.description}
|
||||
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqItem(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpenn] = useState(false);
|
||||
const contentId = useId();
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className={cn("border-b border-dashed", props.className)}>
|
||||
<h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpenn(!isOpen)}
|
||||
aria-controls={contentId}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
"w-full justify-between h-auto py-5 text-base text-muted-foreground hover:bg-transparent pl-0 text-wrap text-left gap-6",
|
||||
isOpen && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{props.title}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground text-base leading-7 pb-6 max-w-4xl",
|
||||
!isOpen && "hidden",
|
||||
)}
|
||||
id={contentId}
|
||||
>
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GridPattern } from "@/components/ui/background-patterns";
|
||||
|
||||
export function GridPatternEmbedContainer(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 sm:rounded-xl sm:overflow-hidden relative">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="text-border dark:text-border/70 hidden lg:block"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom right,white,transparent,transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="z-10 min-w-0">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { CircleQuestionMarkIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
@@ -16,7 +16,6 @@ const client = createThirdwebClient({
|
||||
});
|
||||
|
||||
export function CustomChatButton(props: {
|
||||
label: string;
|
||||
examplePrompts: string[];
|
||||
authToken: string;
|
||||
team: Team;
|
||||
@@ -39,21 +38,25 @@ export function CustomChatButton(props: {
|
||||
<>
|
||||
{/* Inline Button (not floating) */}
|
||||
<Button
|
||||
className="gap-2 rounded-full shadow-lg"
|
||||
className={cn(
|
||||
"gap-2 rounded-full shadow-lg fixed right-6 bottom-6 z-50 bg-card",
|
||||
isOpen && "opacity-0",
|
||||
)}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setHasBeenOpened(true);
|
||||
}}
|
||||
variant="default"
|
||||
variant="outline"
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
{props.label}
|
||||
<CircleQuestionMarkIcon className="size-3.5 text-muted-foreground" />
|
||||
Need help?
|
||||
</Button>
|
||||
|
||||
{/* Popup/Modal */}
|
||||
<div
|
||||
className={cn(
|
||||
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-xl",
|
||||
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-2xl",
|
||||
!isOpen && "hidden",
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -61,8 +64,7 @@ export function CustomChatButton(props: {
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-lg">
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
{props.label}
|
||||
Need help?
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close chat"
|
||||
|
||||
@@ -46,7 +46,7 @@ export function AdvancedSearchInput(props: {
|
||||
value={searchType}
|
||||
onValueChange={(value) => setSearchType(value as SearchType)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] bg-card">
|
||||
<SelectTrigger className="w-[140px] bg-background rounded-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -61,7 +61,7 @@ export function AdvancedSearchInput(props: {
|
||||
<div className="flex flex-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="bg-card pl-9 border-r-0 rounded-r-none"
|
||||
className="bg-background pl-9 border-r-0 rounded-r-none rounded-l-full"
|
||||
placeholder={`Search by ${searchType}...`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -86,7 +86,7 @@ export function AdvancedSearchInput(props: {
|
||||
onClick={handleSearch}
|
||||
variant="outline"
|
||||
disabled={!query.trim() || props.isLoading}
|
||||
className="rounded-l-none gap-2 bg-card disabled:opacity-100"
|
||||
className="rounded-l-none rounded-r-full gap-2 bg-background disabled:opacity-100"
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-4" />}
|
||||
Search
|
||||
|
||||
+22
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -264,11 +264,24 @@ export function InAppWalletUsersPageContent(
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col md:flex-row lg:items-center justify-end gap-3">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<UserIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View and manage your project's users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="w-full lg:w-auto lg:min-w-[320px]">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
@@ -277,7 +290,7 @@ export function InAppWalletUsersPageContent(
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-card"
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
@@ -298,11 +311,11 @@ export function InAppWalletUsersPageContent(
|
||||
data={wallets}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
isPending={walletsQuery.isPending}
|
||||
tableContainerClassName="rounded-b-none"
|
||||
tableContainerClassName="rounded-none border-x-0 border-b-0"
|
||||
title="in-app wallets"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
|
||||
<div className="flex justify-center gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={activePage === 1 || walletsQuery.isPending}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
|
||||
function AnnouncementBannerUI(props: {
|
||||
href: string;
|
||||
label: string;
|
||||
@@ -44,11 +45,12 @@ function AnnouncementBannerUI(props: {
|
||||
}
|
||||
|
||||
export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBannerUI
|
||||
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
trackingLabel="product-rebrand"
|
||||
/>
|
||||
);
|
||||
// return (
|
||||
// <AnnouncementBannerUI
|
||||
// href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
// label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
// trackingLabel="product-rebrand"
|
||||
// />
|
||||
// );
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
const vaultTokens = await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
@@ -74,6 +74,13 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const managementAccessToken = vaultTokens.managementToken?.accessToken;
|
||||
|
||||
if (!managementAccessToken) {
|
||||
throw new Error("Missing management access token for project wallet");
|
||||
}
|
||||
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -32,3 +32,12 @@ export const NEXT_PUBLIC_DEMO_ENGINE_URL =
|
||||
|
||||
export const NEXT_PUBLIC_THIRDWEB_AI_HOST =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com";
|
||||
|
||||
export const NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID;
|
||||
|
||||
@@ -91,3 +91,8 @@ if (REDIS_URL) {
|
||||
REDIS_URL,
|
||||
);
|
||||
}
|
||||
|
||||
// DO NOT TAINT THESE VALUES (for now)
|
||||
export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
|
||||
export const PAYMENT_METHOD_CONFIGURATION =
|
||||
process.env.PAYMENT_METHOD_CONFIGURATION || "";
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getVercelEnv } from "@/utils/vercel";
|
||||
export function getConfiguredThirdwebClient(options: {
|
||||
secretKey: string | undefined;
|
||||
teamId: string | undefined;
|
||||
clientId?: string;
|
||||
}): ThirdwebClient {
|
||||
if (getVercelEnv() !== "production") {
|
||||
// if not on production: run this when creating a client to set the domains
|
||||
@@ -77,7 +78,8 @@ export function getConfiguredThirdwebClient(options: {
|
||||
}
|
||||
|
||||
// During build time, provide fallbacks if credentials are missing
|
||||
const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
|
||||
const clientId =
|
||||
options.clientId || NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
|
||||
const secretKey = options.secretKey || undefined;
|
||||
|
||||
return createThirdwebClient({
|
||||
|
||||
@@ -379,12 +379,27 @@ export function useEngineTransactions(params: {
|
||||
page?: number;
|
||||
status?: EngineStatus;
|
||||
};
|
||||
id?: string;
|
||||
}) {
|
||||
const { instanceUrl, autoUpdate, authToken } = params;
|
||||
const { instanceUrl, autoUpdate, authToken, id } = params;
|
||||
|
||||
return useQuery({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
if (id) {
|
||||
const res = await fetch(`${instanceUrl}transaction/status/${id}`, {
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
const transaction = (json.result as Transaction) || {};
|
||||
return {
|
||||
transactions: [transaction],
|
||||
totalCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${instanceUrl}transaction/get-all`);
|
||||
if (params.queryParams) {
|
||||
for (const key in params.queryParams) {
|
||||
@@ -402,7 +417,6 @@ export function useEngineTransactions(params: {
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return (json.result as TransactionResponse) || {};
|
||||
},
|
||||
queryKey: engineKeys.transactions(instanceUrl, params),
|
||||
|
||||
@@ -17,18 +17,21 @@ export async function resolveEns(
|
||||
ensName: await resolveName({
|
||||
address: ensNameOrAddress,
|
||||
client,
|
||||
}),
|
||||
}).catch(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isValidENSName(ensNameOrAddress)) {
|
||||
throw new Error("Invalid ENS name");
|
||||
return {
|
||||
address: null,
|
||||
ensName: null,
|
||||
};
|
||||
}
|
||||
|
||||
const address = await resolveAddress({
|
||||
client,
|
||||
name: ensNameOrAddress,
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
if (!address) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const PROJECT_WALLET_LABEL_SUFFIX = " Wallet";
|
||||
const PROJECT_WALLET_LABEL_MAX_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Builds the default label for a project's primary server wallet.
|
||||
* Ensures a stable naming convention so the wallet can be identified across flows.
|
||||
*/
|
||||
export function getProjectWalletLabel(projectName: string | undefined) {
|
||||
const baseName = projectName?.trim() || "Project";
|
||||
const maxBaseLength = Math.max(
|
||||
1,
|
||||
PROJECT_WALLET_LABEL_MAX_LENGTH - PROJECT_WALLET_LABEL_SUFFIX.length,
|
||||
);
|
||||
|
||||
const normalizedBase =
|
||||
baseName.length > maxBaseLength
|
||||
? baseName.slice(0, maxBaseLength).trimEnd()
|
||||
: baseName;
|
||||
|
||||
return `${normalizedBase}${PROJECT_WALLET_LABEL_SUFFIX}`;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ async function fetchContractName(chainId: number, contractAddress: string) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
export const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import "server-only";
|
||||
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getProjectWalletLabel } from "@/lib/project-wallet";
|
||||
|
||||
export type ProjectWalletSummary = {
|
||||
id: string;
|
||||
address: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type VaultWalletListItem = {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata?: {
|
||||
label?: string;
|
||||
projectId?: string;
|
||||
teamId?: string;
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getProjectWallet(
|
||||
project: Project,
|
||||
): Promise<ProjectWalletSummary | undefined> {
|
||||
const engineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
engineCloudService?.managementAccessToken || undefined;
|
||||
const projectWalletAddress = (
|
||||
engineCloudService as { projectWalletAddress?: string } | undefined
|
||||
)?.projectWalletAddress;
|
||||
|
||||
if (
|
||||
!managementAccessToken ||
|
||||
!NEXT_PUBLIC_THIRDWEB_VAULT_URL ||
|
||||
!projectWalletAddress
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: 0,
|
||||
// @ts-expect-error - SDK expects snake_case for pagination arguments
|
||||
page_size: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items = response.data.items as VaultWalletListItem[] | undefined;
|
||||
|
||||
if (!items?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const defaultLabel = getProjectWalletLabel(project.name);
|
||||
|
||||
const serverWallets = items.filter(
|
||||
(item) => item.metadata?.projectId === project.id,
|
||||
);
|
||||
|
||||
const defaultWallet = serverWallets.find(
|
||||
(item) =>
|
||||
item.address.toLowerCase() === projectWalletAddress.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!defaultWallet) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: defaultWallet.id,
|
||||
address: defaultWallet.address,
|
||||
label: defaultWallet.metadata?.label ?? defaultLabel,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load project wallet", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
|
||||
selectedTextBg: "hsl(var(--inverted))",
|
||||
selectedTextColor: "hsl(var(--inverted-foreground))",
|
||||
separatorLine: "hsl(var(--border))",
|
||||
skeletonBg: "hsl(var(--muted))",
|
||||
skeletonBg: "hsl(var(--secondary-foreground)/15%)",
|
||||
success: "hsl(var(--success-text))",
|
||||
tertiaryBg: "hsl(var(--muted)/50%)",
|
||||
tertiaryBg: "hsl(var(--muted)/30%)",
|
||||
tooltipBg: "hsl(var(--popover))",
|
||||
tooltipText: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 536 KiB |
-37
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type ChainlistPaginationProps = {
|
||||
totalPages: number;
|
||||
activePage: number;
|
||||
};
|
||||
|
||||
export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
|
||||
activePage,
|
||||
totalPages,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<PaginationButtons
|
||||
activePage={activePage}
|
||||
onPageClick={(page) => router.push(createPageURL(page))}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon, XCircleIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
function cleanUrl(url: string) {
|
||||
if (url.endsWith("?")) {
|
||||
return url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC = () => {
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// reset the input if the query param is removed
|
||||
if (inputRef.current?.value && !searchParams?.get("query")) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
if (term) {
|
||||
params.set("query", term);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
// always delete the page number when searching
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search by token name or symbol"
|
||||
ref={inputRef}
|
||||
/>
|
||||
{searchParams?.has("query") && (
|
||||
<Button
|
||||
className="-translate-y-1/2 absolute top-[50%] right-0 bg-background text-muted-foreground opacity-0 transition duration-300 ease-in-out group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
params.delete("query");
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<XCircleIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type QueryTypeProps = {
|
||||
activeType: "origin" | "destination";
|
||||
};
|
||||
|
||||
export const QueryType: React.FC<QueryTypeProps> = ({ activeType }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(type: "origin" | "destination") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("type", type);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<ToolTipLabel contentClassName="w-full" label="Origin">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("origin"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeType === "origin" ? "default" : "outline"}
|
||||
>
|
||||
<ArrowUpRightIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<ToolTipLabel contentClassName="w-full" label="Destination">
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("destination"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeType === "destination" ? "default" : "outline"}
|
||||
>
|
||||
<ArrowDownLeftIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Grid2X2Icon, ListIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type RouteListViewProps = {
|
||||
activeView: "grid" | "table";
|
||||
};
|
||||
|
||||
export const RouteListView: React.FC<RouteListViewProps> = ({ activeView }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(view: "grid" | "table") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("view", view);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("table"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeView === "table" ? "default" : "outline"}
|
||||
>
|
||||
<ListIcon strokeWidth={1} />
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("grid"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeView === "grid" ? "default" : "outline"}
|
||||
>
|
||||
<Grid2X2Icon strokeWidth={1} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
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";
|
||||
|
||||
type RouteListCardProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol: string;
|
||||
originTokenName: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol: string;
|
||||
destinationTokenName: string;
|
||||
};
|
||||
|
||||
export async function RouteListCard({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenName,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenName,
|
||||
}: RouteListCardProps) {
|
||||
const [
|
||||
originChain,
|
||||
destinationChain,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: originTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: destinationTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Card className="h-full w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={originTokenAddress}
|
||||
className="size-8 rounded-full border border-muted-foreground"
|
||||
src={resolvedOriginTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={destinationTokenAddress}
|
||||
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenName={originTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenName={destinationTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenName(props: {
|
||||
tokenAddress: string;
|
||||
tokenName: string;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
if (isERC20) {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-1.5 font-normal text-base group/link hover:underline underline-offset-4 decoration-muted-foreground/50 decoration-dotted"
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenName}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground group-hover/link:text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-left font-normal text-base">
|
||||
{props.chainMetadata.nativeCurrency.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
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";
|
||||
|
||||
type RouteListRowProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol?: string;
|
||||
originTokenName?: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol?: string;
|
||||
destinationTokenName?: string;
|
||||
};
|
||||
|
||||
export async function RouteListRow({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenSymbol,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenSymbol,
|
||||
}: RouteListRowProps) {
|
||||
const [
|
||||
originChain,
|
||||
destinationChain,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: originTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: destinationTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow className="group">
|
||||
<TableCell>
|
||||
<TokenInfo
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenIconUri={resolvedOriginTokenIconUri}
|
||||
tokenSymbol={originTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{originChain.name}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<TokenInfo
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenIconUri={resolvedDestinationTokenIconUri}
|
||||
tokenSymbol={destinationTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{destinationChain.name}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{props.tokenIconUri ? (
|
||||
<Img
|
||||
alt={props.tokenAddress}
|
||||
className="size-7 rounded-full border border-border/50"
|
||||
src={props.tokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{isERC20 ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-auto w-auto py-1 px-1.5 gap-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenSymbol || shortenAddress(props.tokenAddress)}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="font-medium text-sm px-1.5 py-1">
|
||||
{props.chainMetadata.nativeCurrency.symbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
import type { Address } from "thirdweb";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "./routelist-card";
|
||||
import { RouteListRow } from "./routelist-row";
|
||||
|
||||
export type SearchParams = Partial<{
|
||||
includeDeprecated: boolean;
|
||||
query: string;
|
||||
page: number;
|
||||
type: "origin" | "destination";
|
||||
// maybe later we'll have a page size param?
|
||||
// pageSize: number;
|
||||
view: "table" | "grid";
|
||||
}>;
|
||||
|
||||
// 120 is divisible by 2, 3, and 4 so card layout looks nice
|
||||
const DEFAULT_PAGE_SIZE = 120;
|
||||
|
||||
async function getRoutesToRender(params: SearchParams) {
|
||||
const filters: Partial<{
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.originTokenAddress = params.query as Address;
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.originChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.originQuery = params.query;
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.destinationTokenAddress = params.query as Address;
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.destinationQuery = params.query;
|
||||
}
|
||||
}
|
||||
const routes = await getRoutes({
|
||||
destinationQuery: filters.destinationQuery,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
offset: DEFAULT_PAGE_SIZE * ((params.page || 1) - 1),
|
||||
originQuery: filters.originQuery,
|
||||
});
|
||||
|
||||
return {
|
||||
filteredCount: routes.meta.filteredCount,
|
||||
routesToRender: routes.data,
|
||||
totalCount: routes.meta.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function RoutesData(props: {
|
||||
searchParams: SearchParams;
|
||||
activeView: "table" | "grid";
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filteredCount / DEFAULT_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{routesToRender.length === 0 ? (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{routesToRender.map((route) => (
|
||||
<RouteListRow
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenName={route.originToken.name}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{routesToRender.map((route) => (
|
||||
<li
|
||||
className="h-full"
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
>
|
||||
<RouteListCard
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenName={route.originToken.name}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination
|
||||
activePage={Number(props.searchParams.page) || 1}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-balance text-center text-muted-foreground text-sm">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{routesToRender.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">
|
||||
{filteredCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { SearchInput } from "./components/client/search";
|
||||
import { QueryType } from "./components/client/type";
|
||||
import { RouteListView } from "./components/client/view";
|
||||
import {
|
||||
RoutesData,
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Payments Routes: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
export default async function RoutesPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const headersList = await headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const activeType = searchParams.type ?? "origin";
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = searchParams.view
|
||||
? searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
|
||||
return (
|
||||
<section className="container mx-auto flex h-full flex-col px-4 py-10">
|
||||
<header className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
|
||||
Routes
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-end gap-4 lg:flex-col">
|
||||
<div className="flex w-full flex-row items-center gap-4">
|
||||
<SearchInput />
|
||||
<QueryType activeType={activeType} />
|
||||
<RouteListView activeView={activeView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with thirdweb Payments
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and chain.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
href="https://portal.thirdweb.com/payments"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10" />
|
||||
<RoutesData
|
||||
activeView={activeView}
|
||||
isLoggedIn={!!authToken}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Address } from "thirdweb";
|
||||
|
||||
export type Route = {
|
||||
originToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
destinationToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originQuery,
|
||||
destinationQuery,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
} = {}) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originQuery) {
|
||||
url.searchParams.set("originQuery", originQuery);
|
||||
}
|
||||
if (destinationQuery) {
|
||||
url.searchParams.set("destinationQuery", destinationQuery);
|
||||
}
|
||||
url.searchParams.set("sortBy", "popularity");
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
next: { revalidate: 60 * 60 },
|
||||
});
|
||||
|
||||
if (!routesResponse.ok) {
|
||||
throw new Error("Failed to fetch routes");
|
||||
}
|
||||
const routes: {
|
||||
data: Route[];
|
||||
meta: { totalCount: number; filteredCount: number };
|
||||
} = await routesResponse.json();
|
||||
|
||||
return routes;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
type ChainSeo = {
|
||||
title: string;
|
||||
description: string;
|
||||
og: {
|
||||
title: string;
|
||||
description: string;
|
||||
site_name: string;
|
||||
url: string;
|
||||
};
|
||||
faqs: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
chain: {
|
||||
chainId: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
nativeCurrency: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
testnet: boolean;
|
||||
is_deprecated: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchChainSeo = unstable_cache(
|
||||
async (chainId: number) => {
|
||||
const url = new URL(
|
||||
`https://seo-pages-generator-5814.zeet-nftlabs.zeet.app/chain/${chainId}`,
|
||||
);
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return res.json() as Promise<ChainSeo>;
|
||||
},
|
||||
["chain-seo"],
|
||||
{ revalidate: 60 * 60 * 24 }, // 24 hours
|
||||
);
|
||||
+12
-16
@@ -1,24 +1,20 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { defineChain, type ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
|
||||
export function BuyFundsSection(props: {
|
||||
chain: ChainMetadata;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 items-center justify-center">
|
||||
<BuyWidget
|
||||
amount="0"
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyAndSwapEmbed
|
||||
isTestnet={props.chain.testnet}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(props.chain.chainId)}
|
||||
client={props.client}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
chain={defineDashboardChain(props.chain.chainId, props.chain)}
|
||||
buyAmount={undefined}
|
||||
tokenAddress={undefined}
|
||||
pageType="chain"
|
||||
/>
|
||||
</section>
|
||||
</GridPatternEmbedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
import { reportChainPageview } from "@/analytics/report";
|
||||
import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
|
||||
export function ChainPageView(props: { chainId: number; is_testnet: boolean }) {
|
||||
useEffectOnce(() => {
|
||||
reportChainPageview({
|
||||
chainId: props.chainId,
|
||||
is_testnet: props.is_testnet,
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
+94
-13
@@ -43,9 +43,7 @@ function useChainStatswithRPC(_rpcUrl: string) {
|
||||
const latency = (performance.now() - startTimeStamp).toFixed(0);
|
||||
|
||||
const blockNumber = Number.parseInt(json.result.number, 16);
|
||||
const blockGasLimit = Number.parseInt(json.result.gasLimit, 16);
|
||||
return {
|
||||
blockGasLimit,
|
||||
blockNumber,
|
||||
latency,
|
||||
};
|
||||
@@ -61,8 +59,70 @@ function useChainStatswithRPC(_rpcUrl: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function useEIP7702Support(_rpcUrl: string) {
|
||||
let rpcUrl = _rpcUrl.replace(
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is expected in this case
|
||||
"${THIRDWEB_API_KEY}",
|
||||
NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
);
|
||||
|
||||
// based on the environment hit dev or production
|
||||
if (hostnameEndsWith(rpcUrl, "rpc.thirdweb.com")) {
|
||||
if (!isProd) {
|
||||
rpcUrl = rpcUrl.replace("rpc.thirdweb.com", "rpc.thirdweb-dev.com");
|
||||
}
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
enabled: !!rpcUrl,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await fetch(rpcUrl, {
|
||||
body: JSON.stringify({
|
||||
id: 1,
|
||||
jsonrpc: "2.0",
|
||||
method: "eth_estimateGas",
|
||||
params: [
|
||||
{
|
||||
from: "0xdeadbeef00000000000000000000000000000000",
|
||||
to: "0xdeadbeef00000000000000000000000000000000",
|
||||
data: "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
value: "0x0",
|
||||
},
|
||||
"latest",
|
||||
{
|
||||
"0xdeadbeef00000000000000000000000000000000": {
|
||||
code: "0xef01000000000000000000000000000000000000000001",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
// If the response has a valid result object, EIP-7702 is enabled
|
||||
return {
|
||||
isSupported: !!json.result,
|
||||
};
|
||||
} catch {
|
||||
// If the request fails or errors, EIP-7702 is not supported
|
||||
return {
|
||||
isSupported: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
queryKey: ["eip-7702-support", { rpcUrl }],
|
||||
refetchInterval: false, // Don't refetch this as it's unlikely to change
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function ChainLiveStats(props: { rpc: string }) {
|
||||
const stats = useChainStatswithRPC(props.rpc);
|
||||
const eip7702Support = useEIP7702Support(props.rpc);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -107,8 +167,8 @@ export function ChainLiveStats(props: { rpc: string }) {
|
||||
)}
|
||||
</PrimaryInfoItem>
|
||||
|
||||
{/* Block Height */}
|
||||
<PrimaryInfoItem title="Block Height" titleIcon={<PulseDot />}>
|
||||
{/* Latest Block */}
|
||||
<PrimaryInfoItem title="Latest Block" titleIcon={<PulseDot />}>
|
||||
{stats.isError ? (
|
||||
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
|
||||
) : stats.data ? (
|
||||
@@ -120,16 +180,37 @@ export function ChainLiveStats(props: { rpc: string }) {
|
||||
)}
|
||||
</PrimaryInfoItem>
|
||||
|
||||
{/* Block Gas Limit */}
|
||||
<PrimaryInfoItem title="Block Gas Limit" titleIcon={<PulseDot />}>
|
||||
{stats.isError ? (
|
||||
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
|
||||
) : stats.data ? (
|
||||
<p className="fade-in-0 animate-in">
|
||||
{stats.data.blockGasLimit ?? "N/A"}
|
||||
</p>
|
||||
{/* EIP-7702 */}
|
||||
<PrimaryInfoItem
|
||||
title="EIP-7702"
|
||||
titleIcon={
|
||||
eip7702Support.data ? (
|
||||
eip7702Support.data.isSupported ? (
|
||||
<ToolTipLabel label="Enabled">
|
||||
<CircleCheckIcon className="size-4 text-success-text" />
|
||||
</ToolTipLabel>
|
||||
) : (
|
||||
<ToolTipLabel label="Disabled">
|
||||
<XIcon className="size-4 text-destructive-text" />
|
||||
</ToolTipLabel>
|
||||
)
|
||||
) : eip7702Support.isError ? (
|
||||
<ToolTipLabel label="Disabled">
|
||||
<XIcon className="size-4 text-destructive-text" />
|
||||
</ToolTipLabel>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{eip7702Support.data ? (
|
||||
eip7702Support.data.isSupported ? (
|
||||
"Available"
|
||||
) : (
|
||||
"Not Available"
|
||||
)
|
||||
) : eip7702Support.isError ? (
|
||||
"Not Available"
|
||||
) : (
|
||||
<div className="flex h-[28px] w-[140px] py-1">
|
||||
<div className="flex h-[28px] w-[80px] py-1">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function ChainOverviewSection(props: { chain: ChainMetadata }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Chain Overview" />
|
||||
<div className="grid grid-cols-1 gap-6 rounded-lg border bg-card p-4 md:grid-cols-2 md:p-6 lg:grid-cols-3 lg:gap-8">
|
||||
<div className="grid grid-cols-1 gap-6 rounded-xl border bg-card p-4 md:grid-cols-2 md:p-6 lg:grid-cols-3 lg:gap-6">
|
||||
{/* Info */}
|
||||
{chain.infoURL && (
|
||||
<PrimaryInfoItem title="Info">
|
||||
|
||||
+8
-6
@@ -1,4 +1,3 @@
|
||||
import { CircleCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadataWithServices } from "@/types/chain";
|
||||
import { products } from "../../../../components/server/products";
|
||||
@@ -24,15 +23,18 @@ export function SupportedProductsSection(props: {
|
||||
{enabledProducts.map((product) => {
|
||||
return (
|
||||
<div
|
||||
className="relative flex gap-3 rounded-lg border bg-card p-4 pr-8 transition-colors hover:border-active-border"
|
||||
className="relative rounded-xl border bg-card p-4 hover:border-active-border"
|
||||
key={product.id}
|
||||
>
|
||||
<CircleCheckIcon className="absolute top-4 right-4 size-5 text-success-text" />
|
||||
<product.icon className="mt-0.5 size-5 shrink-0" />
|
||||
<div className="flex mb-4">
|
||||
<div className="p-2 rounded-full border bg-background">
|
||||
<product.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-1.5 font-medium">
|
||||
<h3 className="mb-1">
|
||||
<Link
|
||||
className="before:absolute before:inset-0"
|
||||
className="before:absolute before:inset-0 text-base font-medium"
|
||||
href={product.link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export function ExplorersSection(props: {
|
||||
key={explorer.url}
|
||||
>
|
||||
<ExternalLinkIcon className="absolute top-4 right-4 size-4 text-muted-foreground" />
|
||||
<h3 className="mb-1 font-semibold text-base capitalize">
|
||||
<h3 className="mb-1 font-medium text-base capitalize">
|
||||
{explorer.name}
|
||||
</h3>
|
||||
<Link
|
||||
|
||||
+1
-3
@@ -6,9 +6,7 @@ export function PrimaryInfoItem(props: {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-base text-muted-foreground">
|
||||
{props.title}
|
||||
</h3>
|
||||
<h3 className="text-base text-muted-foreground">{props.title}</h3>
|
||||
{props.titleIcon}
|
||||
</div>
|
||||
{props.children}
|
||||
|
||||
+38
-22
@@ -22,38 +22,51 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { getChain, getCustomChainMetadata } from "../../utils";
|
||||
import { fetchChainSeo } from "./apis/chain-seo";
|
||||
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
|
||||
import { ChainPageView } from "./components/client/chain-pageview";
|
||||
import { ChainHeader } from "./components/server/chain-header";
|
||||
|
||||
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
|
||||
type Params = Promise<{ chain_id: string }>;
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
params: Params;
|
||||
}): Promise<Metadata | undefined> {
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
|
||||
const title = `${sanitizedChainName}: RPC and Chain Settings`;
|
||||
const chainSeo = await fetchChainSeo(Number(chain.chainId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const description = `Use the best ${sanitizedChainName} RPC and add to your wallet. Discover the chain ID, native token, explorers, and ${
|
||||
chain.testnet && chain.faucets?.length ? "faucet options" : "more"
|
||||
}.`;
|
||||
if (!chainSeo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
title: chainSeo.title,
|
||||
description: chainSeo.description,
|
||||
metadataBase: new URL("https://thirdweb.com"),
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
title: chainSeo.og.title,
|
||||
description: chainSeo.og.description,
|
||||
siteName: "thirdweb",
|
||||
type: "website",
|
||||
url: "https://thirdweb.com",
|
||||
},
|
||||
twitter: {
|
||||
title: chainSeo.og.title,
|
||||
description: chainSeo.og.description,
|
||||
card: "summary_large_image",
|
||||
creator: "@thirdweb",
|
||||
site: "@thirdweb",
|
||||
},
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
// this is the dashboard layout file
|
||||
export default async function ChainPageLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ chain_id: string }>;
|
||||
params: Params;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
@@ -66,11 +79,14 @@ export default async function ChainPageLayout(props: {
|
||||
redirect(chain.slug);
|
||||
}
|
||||
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const customChainMetadata = getCustomChainMetadata(chain.chainId);
|
||||
const chainSeo = await fetchChainSeo(chain.chainId);
|
||||
const client = getClientThirdwebClient(undefined);
|
||||
const description = customChainMetadata?.about || chainSeo?.description;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<ChainPageView chainId={chain.chainId} is_testnet={chain.testnet} />
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
@@ -121,7 +137,7 @@ export default async function ChainPageLayout(props: {
|
||||
<ChainHeader
|
||||
chain={chain}
|
||||
client={client}
|
||||
headerImageUrl={chainMetadata?.headerImgUrl}
|
||||
headerImageUrl={customChainMetadata?.headerImgUrl}
|
||||
logoUrl={chain.icon?.url}
|
||||
/>
|
||||
|
||||
@@ -129,7 +145,7 @@ export default async function ChainPageLayout(props: {
|
||||
|
||||
<div className="flex flex-col gap-3 md:gap-2">
|
||||
{/* Gas Sponsored badge - Mobile */}
|
||||
{chainMetadata?.gasSponsored && (
|
||||
{customChainMetadata?.gasSponsored && (
|
||||
<div className="flex md:hidden">
|
||||
<GasSponsoredBadge />
|
||||
</div>
|
||||
@@ -151,7 +167,7 @@ export default async function ChainPageLayout(props: {
|
||||
)}
|
||||
|
||||
{/* Gas Sponsored badge - Desktop */}
|
||||
{chainMetadata?.gasSponsored && (
|
||||
{customChainMetadata?.gasSponsored && (
|
||||
<div className="hidden md:block">
|
||||
<GasSponsoredBadge />
|
||||
</div>
|
||||
@@ -159,9 +175,9 @@ export default async function ChainPageLayout(props: {
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
{chainMetadata?.about && (
|
||||
<p className="mb-2 whitespace-pre-line text-muted-foreground text-sm lg:text-base">
|
||||
{chainMetadata.about}
|
||||
{description && (
|
||||
<p className="mb-2 whitespace-pre-line text-muted-foreground text-sm lg:text-base text-pretty max-w-3xl">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { useId } from "react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
@@ -18,8 +17,8 @@ export const size = {
|
||||
export const contentType = "image/png";
|
||||
|
||||
const TWLogo: React.FC = () => {
|
||||
const clipPathId = useId();
|
||||
const linearGradientId = useId();
|
||||
const clipPathId = "og-tw-logo-clip-path";
|
||||
const linearGradientId = "og-tw-logo-linear-gradient";
|
||||
return (
|
||||
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
|
||||
<svg
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { getRawAccount } from "@/api/account/get-account";
|
||||
import { FaqSection } from "@/components/blocks/faq-section";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { getChain, getCustomChainMetadata } from "../../utils";
|
||||
import { fetchChainSeo } from "./apis/chain-seo";
|
||||
import { BuyFundsSection } from "./components/client/BuyFundsSection";
|
||||
import { ChainOverviewSection } from "./components/server/ChainOverviewSection";
|
||||
import { ChainCTA } from "./components/server/cta-card";
|
||||
@@ -9,22 +11,27 @@ import { ExplorersSection } from "./components/server/explorer-section";
|
||||
import { FaucetSection } from "./components/server/FaucetSection";
|
||||
import { SupportedProductsSection } from "./components/server/SupportedProductsSection";
|
||||
|
||||
export default async function Page(props: {
|
||||
type Props = {
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}) {
|
||||
};
|
||||
|
||||
export default async function Page(props: Props) {
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const customChainMetadata = getCustomChainMetadata(Number(params.chain_id));
|
||||
const chainSeo = await fetchChainSeo(Number(chain.chainId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const client = getClientThirdwebClient();
|
||||
const isDeprecated = chain.status === "deprecated";
|
||||
|
||||
const account = await getRawAccount();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{/* Custom CTA */}
|
||||
{(chainMetadata?.cta?.title || chainMetadata?.cta?.description) && (
|
||||
<ChainCTA {...chainMetadata.cta} />
|
||||
{(customChainMetadata?.cta?.title ||
|
||||
customChainMetadata?.cta?.description) && (
|
||||
<ChainCTA {...customChainMetadata.cta} />
|
||||
)}
|
||||
|
||||
{/* Deprecated Alert */}
|
||||
@@ -44,7 +51,7 @@ export default async function Page(props: {
|
||||
{chain.testnet ? (
|
||||
<FaucetSection chain={chain} client={client} isLoggedIn={!!account} />
|
||||
) : chain.services.find((c) => c.service === "pay" && c.enabled) ? (
|
||||
<BuyFundsSection chain={chain} client={client} />
|
||||
<BuyFundsSection chain={chain} />
|
||||
) : null}
|
||||
|
||||
{/* Chain Overview */}
|
||||
@@ -58,6 +65,12 @@ export default async function Page(props: {
|
||||
{chain.services.filter((s) => s.enabled).length > 0 && (
|
||||
<SupportedProductsSection services={chain.services} />
|
||||
)}
|
||||
|
||||
{chainSeo?.faqs && chainSeo.faqs.length > 0 && (
|
||||
<div className="py-10">
|
||||
<FaqSection faqs={chainSeo.faqs} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { formatNumber } from "@/lib/search";
|
||||
import {
|
||||
type AnalyticsQueryParams,
|
||||
type TotalQueryResult,
|
||||
@@ -376,7 +377,7 @@ function AnalyticsStatUI(props: { label: string; data: number | undefined }) {
|
||||
<SkeletonContainer
|
||||
loadedData={props.data}
|
||||
render={(v) => {
|
||||
return <dd className="font-normal text-xl">{v.toLocaleString()}</dd>;
|
||||
return <dd className="font-normal text-xl">{formatNumber(v)}</dd>;
|
||||
}}
|
||||
skeletonData={10000}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export async function getContractEventAnalytics(params: {
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Failed to fetch analytics data: ${errorText}`);
|
||||
throw new Error(`Failed to fetch events analytics data: ${errorText}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
|
||||
+13
-8
@@ -26,12 +26,18 @@ export async function getContractFunctionBreakdown(params: {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<FunctionBreakdownEntry[]> {
|
||||
const daysDifference =
|
||||
params.startDate && params.endDate
|
||||
? Math.ceil(
|
||||
(params.endDate.getTime() - params.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 30;
|
||||
const queryParams = [
|
||||
`chain=${params.chainId}`,
|
||||
"group_by=time",
|
||||
"group_by=day",
|
||||
"group_by=function_selector",
|
||||
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
|
||||
"aggregate=count(*) as count",
|
||||
`limit=${daysDifference * 10}`, // at most 10 functions per day
|
||||
params.startDate
|
||||
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
|
||||
: "",
|
||||
@@ -68,17 +74,16 @@ export async function getContractFunctionBreakdown(params: {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"time" in value &&
|
||||
"day" in value &&
|
||||
"count" in value &&
|
||||
"function_selector" in value &&
|
||||
typeof value.function_selector === "string" &&
|
||||
typeof value.time === "string" &&
|
||||
typeof value.count === "number"
|
||||
typeof value.day === "string"
|
||||
) {
|
||||
collectedAggregations.push({
|
||||
count: value.count,
|
||||
count: Number(value.count),
|
||||
function_selector: value.function_selector,
|
||||
time: value.time,
|
||||
time: value.day,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+15
-9
@@ -30,11 +30,17 @@ export async function getContractTransactionAnalytics(params: {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<TransactionAnalyticsEntry[]> {
|
||||
const daysDifference =
|
||||
params.startDate && params.endDate
|
||||
? Math.ceil(
|
||||
(params.endDate.getTime() - params.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 30;
|
||||
const queryParams = [
|
||||
`chain=${params.chainId}`,
|
||||
"group_by=time",
|
||||
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
|
||||
"aggregate=count(block_timestamp) as count",
|
||||
"group_by=day",
|
||||
`limit=${daysDifference}`,
|
||||
params.startDate
|
||||
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
|
||||
: "",
|
||||
@@ -55,7 +61,8 @@ export async function getContractTransactionAnalytics(params: {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch analytics data");
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Failed to fetch transaction analytics data: ${errorText}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
@@ -67,14 +74,13 @@ export async function getContractTransactionAnalytics(params: {
|
||||
if (
|
||||
typeof tx === "object" &&
|
||||
tx !== null &&
|
||||
"time" in tx &&
|
||||
"day" in tx &&
|
||||
"count" in tx &&
|
||||
typeof tx.time === "string" &&
|
||||
typeof tx.count === "number"
|
||||
typeof tx.day === "string"
|
||||
) {
|
||||
returnValue.push({
|
||||
count: tx.count,
|
||||
time: new Date(tx.time),
|
||||
count: Number(tx.count),
|
||||
time: new Date(tx.day),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+16
-9
@@ -30,11 +30,18 @@ export async function getContractUniqueWalletAnalytics(params: {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<TransactionAnalyticsEntry[]> {
|
||||
const daysDifference =
|
||||
params.startDate && params.endDate
|
||||
? Math.ceil(
|
||||
(params.endDate.getTime() - params.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 30;
|
||||
const queryParams = [
|
||||
`chain=${params.chainId}`,
|
||||
"group_by=time",
|
||||
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
|
||||
"aggregate=count(distinct from_address) as count",
|
||||
"group_by=day",
|
||||
"aggregate=count(distinct from_address)",
|
||||
`limit=${daysDifference}`,
|
||||
params.startDate
|
||||
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
|
||||
: "",
|
||||
@@ -55,7 +62,8 @@ export async function getContractUniqueWalletAnalytics(params: {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch analytics data");
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Failed to fetch wallet analytics data: ${errorText}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
@@ -67,14 +75,13 @@ export async function getContractUniqueWalletAnalytics(params: {
|
||||
if (
|
||||
typeof tx === "object" &&
|
||||
tx !== null &&
|
||||
"time" in tx &&
|
||||
"day" in tx &&
|
||||
"count" in tx &&
|
||||
typeof tx.time === "string" &&
|
||||
typeof tx.count === "number"
|
||||
typeof tx.day === "string"
|
||||
) {
|
||||
returnValue.push({
|
||||
count: tx.count,
|
||||
time: new Date(tx.time),
|
||||
count: Number(tx.count),
|
||||
time: new Date(tx.day),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ export async function getTotalContractEvents(params: {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch analytics data");
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Failed to fetch events analytics data: ${errorText}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
|
||||
+9
-7
@@ -6,7 +6,7 @@ type InsightResponse = {
|
||||
aggregations: [
|
||||
{
|
||||
0: {
|
||||
total: number;
|
||||
count: number;
|
||||
};
|
||||
},
|
||||
];
|
||||
@@ -19,10 +19,9 @@ export async function getTotalContractTransactions(params: {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
}): Promise<{ count: number }> {
|
||||
const queryParams = [
|
||||
`chain=${params.chainId}`,
|
||||
"aggregate=count(block_number) as total",
|
||||
].join("&");
|
||||
const queryParams = [`chain=${params.chainId}`, "aggregate=count()"].join(
|
||||
"&",
|
||||
);
|
||||
|
||||
const res = await fetch(
|
||||
`https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`,
|
||||
@@ -34,12 +33,15 @@ export async function getTotalContractTransactions(params: {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch analytics data");
|
||||
const errorText = await res.text();
|
||||
throw new Error(
|
||||
`Failed to fetch transactions analytics data: ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
|
||||
return {
|
||||
count: json.aggregations[0][0].total,
|
||||
count: json.aggregations[0][0].count,
|
||||
};
|
||||
}
|
||||
|
||||
+7
-4
@@ -6,7 +6,7 @@ type InsightResponse = {
|
||||
aggregations: [
|
||||
{
|
||||
0: {
|
||||
total: number;
|
||||
count: number;
|
||||
};
|
||||
},
|
||||
];
|
||||
@@ -21,7 +21,7 @@ export async function getTotalContractUniqueWallets(params: {
|
||||
}): Promise<{ count: number }> {
|
||||
const queryParams = [
|
||||
`chain=${params.chainId}`,
|
||||
"aggregate=count(distinct from_address) as total",
|
||||
"aggregate=count(distinct from_address)",
|
||||
].join("&");
|
||||
|
||||
const res = await fetch(
|
||||
@@ -34,12 +34,15 @@ export async function getTotalContractUniqueWallets(params: {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch analytics data");
|
||||
const errorText = await res.text();
|
||||
throw new Error(
|
||||
`Failed to fetch unique wallets analytics data: ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
|
||||
return {
|
||||
count: json.aggregations[0][0].total,
|
||||
count: json.aggregations[0][0].count,
|
||||
};
|
||||
}
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ export function NFTGetAllTable({
|
||||
includeOwners: true,
|
||||
start: currentPage * pageSize,
|
||||
tokenByIndex,
|
||||
useIndexer: false, // TEMPORARY, TODO: Remove when this is fixed in insight
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { ImageResponse } from "@vercel/og";
|
||||
import { useId } from "react";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { API_ROUTES_CLIENT_ID } from "@/constants/server-envs";
|
||||
@@ -62,8 +61,9 @@ function shortenString(str: string) {
|
||||
return `${str.substring(0, 7)}...${str.substring(str.length - 5)}`;
|
||||
}
|
||||
|
||||
const gradientId = "og-brand-icon-gradient";
|
||||
|
||||
const OgBrandIcon: React.FC = () => {
|
||||
const gradientId = useId();
|
||||
return (
|
||||
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
|
||||
<svg
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b bg-card">
|
||||
<div className="border-b">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-8xl justify-between py-3",
|
||||
|
||||
+3
@@ -28,6 +28,9 @@ export function PublicPageConnectButton(props: {
|
||||
connectButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
detailsButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
connectModal={{
|
||||
privacyPolicyUrl: "/privacy-policy",
|
||||
showThirdwebBranding: false,
|
||||
|
||||
+2
@@ -5,11 +5,13 @@ import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
export function AssetPageView(props: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
is_testnet: boolean;
|
||||
}) {
|
||||
useEffectOnce(() => {
|
||||
reportAssetPageview({
|
||||
assetType: props.assetType,
|
||||
chainId: props.chainId,
|
||||
is_testnet: props.is_testnet,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
try {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch token price data", await res.text());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
|
||||
return priceData;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -76,7 +76,7 @@ export function ContractHeaderUI(props: {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-8 relative",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import type { Chain, ThirdwebClient } from "thirdweb";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
reportAssetBuySuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function BuyTokenEmbed(props: {
|
||||
client: ThirdwebClient;
|
||||
chain: Chain;
|
||||
tokenAddress: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<BuyWidget
|
||||
amount="1"
|
||||
chain={props.chain}
|
||||
className="!rounded-xl !w-full"
|
||||
client={props.client}
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
}}
|
||||
onError={(e) => {
|
||||
const errorMessage = parseError(e);
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
});
|
||||
}}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
tokenAddress={props.tokenAddress as `0x${string}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-264
@@ -1,264 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { differenceInCalendarDays, format } from "date-fns";
|
||||
import { ArrowDownIcon, ArrowUpIcon, InfoIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TokenPriceData } from "../_apis/token-price-data";
|
||||
|
||||
function PriceChartUI(props: {
|
||||
showTimeOfDay: boolean;
|
||||
data: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
}) {
|
||||
const data = props.data.map((item) => ({
|
||||
price: item.price_usd,
|
||||
time: new Date(item.date).getTime(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
className="border-none bg-transparent p-0"
|
||||
config={{
|
||||
price: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Price",
|
||||
},
|
||||
}}
|
||||
data={data}
|
||||
hideLabel={false}
|
||||
isPending={false}
|
||||
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return tokenPriceUSDFormatter.format(value as number);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 8,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
style: "currency",
|
||||
});
|
||||
|
||||
const marketCapFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
style: "currency",
|
||||
});
|
||||
|
||||
const holdersFormatter = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
const percentChangeFormatter = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
|
||||
function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
|
||||
return (_v: string, item: unknown) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return format(
|
||||
new Date(time),
|
||||
includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy",
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function TokenStats(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
tokenPriceData: TokenPriceData;
|
||||
}) {
|
||||
const [interval, setInterval] = useState<Interval>("max");
|
||||
|
||||
const filteredHistoricalPrices = useMemo(() => {
|
||||
const currentDate = new Date();
|
||||
|
||||
return params.tokenPriceData.historical_prices.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
const maxDiff =
|
||||
interval === "24h"
|
||||
? 1
|
||||
: interval === "7d"
|
||||
? 7
|
||||
: interval === "30d"
|
||||
? 30
|
||||
: interval === "1y"
|
||||
? 365
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return differenceInCalendarDays(currentDate, date) <= maxDiff;
|
||||
});
|
||||
}, [params.tokenPriceData, interval]);
|
||||
|
||||
const priceUsd = params.tokenPriceData.price_usd;
|
||||
const percentChange24h = params.tokenPriceData.percent_change_24h;
|
||||
const marketCap = params.tokenPriceData.market_cap_usd;
|
||||
const holders = params.tokenPriceData.holders;
|
||||
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(percentChange24h),
|
||||
);
|
||||
|
||||
const isAlmostZeroChange =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border">
|
||||
{/* price and change */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"font-bold text-3xl lg:text-4xl tracking-tight",
|
||||
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
|
||||
)}
|
||||
>
|
||||
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
|
||||
</p>
|
||||
<Badge
|
||||
className="gap-2 lg:text-sm text-xs px-1.5"
|
||||
variant={
|
||||
isAlmostZeroChange
|
||||
? "default"
|
||||
: percentChange24h > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntervalSelector interval={interval} setInterval={setInterval} />
|
||||
</div>
|
||||
|
||||
<div className="px-0">
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<TokenStat
|
||||
label="Market Cap"
|
||||
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
|
||||
value={
|
||||
typeof marketCap === "number"
|
||||
? marketCapFormatter.format(marketCap)
|
||||
: marketCap
|
||||
}
|
||||
/>
|
||||
|
||||
<TokenStat
|
||||
label="Number of Holders"
|
||||
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
|
||||
value={
|
||||
// show 0 value as N/A
|
||||
holders === 0
|
||||
? "N/A"
|
||||
: typeof holders === "number"
|
||||
? holdersFormatter.format(holders)
|
||||
: holders
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenStat(props: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-muted-foreground text-sm">{props.label}</p>
|
||||
<ToolTipLabel label={props.tooltip}>
|
||||
<InfoIcon className="size-3.5 text-muted-foreground" />
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{props.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Interval = "24h" | "7d" | "30d" | "1y" | "max";
|
||||
|
||||
function IntervalSelector(props: {
|
||||
interval: Interval;
|
||||
setInterval: (timeframe: Interval) => void;
|
||||
}) {
|
||||
const intervals: Record<Interval, string> = {
|
||||
"1y": "1Y",
|
||||
"7d": "1W",
|
||||
"24h": "1D",
|
||||
"30d": "1M",
|
||||
max: "MAX",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(intervals).map(([key, value]) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
|
||||
props.interval === key && "border-active-border bg-accent",
|
||||
)}
|
||||
key={key}
|
||||
onClick={() => props.setInterval(key as Interval)}
|
||||
size="sm"
|
||||
tabIndex={0}
|
||||
variant="outline"
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -73,8 +74,13 @@ function RecentTransfersUI(props: {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-xl">
|
||||
<div className="flex mb-3">
|
||||
<div className="rounded-full border p-2 bg-background">
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-1">
|
||||
Recent Transfers
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -194,7 +200,7 @@ function RecentTransfersUI(props: {
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-xl border-x border-b bg-card px-6 py-5">
|
||||
<Button
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.page === 0 || props.isPending}
|
||||
|
||||
+3
@@ -122,6 +122,7 @@ export function TokenDropClaim(props: {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
toast.error("Failed to approve spending", {
|
||||
@@ -160,6 +161,7 @@ export function TokenDropClaim(props: {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
toast.error("Failed to buy tokens", {
|
||||
@@ -172,6 +174,7 @@ export function TokenDropClaim(props: {
|
||||
assetType: "coin",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
setStepsUI({
|
||||
|
||||
+9
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import {
|
||||
@@ -35,10 +36,18 @@ export function ContractAnalyticsOverview(props: {
|
||||
margin={{
|
||||
bottom: 20,
|
||||
}}
|
||||
className="rounded-xl"
|
||||
header={{
|
||||
headerClassName: "p-4 lg:p-6",
|
||||
title: "Analytics",
|
||||
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
|
||||
icon: (
|
||||
<div className="flex mb-3">
|
||||
<div className="rounded-full border p-2 bg-background">
|
||||
<BarChart3Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
description:
|
||||
"View trends of transactions, events and unique wallets interacting with this contract over time",
|
||||
}}
|
||||
|
||||
+11
-32
@@ -1,42 +1,21 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { Bridge, type ThirdwebClient } from "thirdweb";
|
||||
|
||||
export async function fetchTokenInfoFromBridge(params: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
clientId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
|
||||
{
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`Failed to fetch token info from bridge: ${await res.text()}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data: Array<{
|
||||
iconUri: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
name: string;
|
||||
symbol: string;
|
||||
priceUsd: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
return data.data[0];
|
||||
const res = await Bridge.tokens({
|
||||
client: params.client,
|
||||
chainId: params.chainId,
|
||||
tokenAddress: params.tokenAddress,
|
||||
includePrices: true,
|
||||
limit: 1,
|
||||
});
|
||||
return res[0];
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+135
-65
@@ -1,20 +1,19 @@
|
||||
import { BarChart3Icon, DollarSignIcon, TrendingUpIcon } from "lucide-react";
|
||||
import { cookies } from "next/headers";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
|
||||
import { GridPattern } from "@/components/ui/background-patterns";
|
||||
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
|
||||
import { HAS_USED_DASHBOARD } from "@/constants/cookie";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { AssetPageView } from "../_components/asset-page-view";
|
||||
import { getContractCreator } from "../_components/getContractCreator";
|
||||
import { PageHeader } from "../_components/PageHeader";
|
||||
import { getTokenPriceData } from "./_apis/token-price-data";
|
||||
import { ContractHeaderUI } from "./_components/ContractHeader";
|
||||
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
|
||||
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
|
||||
import { BuyTokenEmbed } from "./_components/PayEmbedSection";
|
||||
import { TokenStats } from "./_components/PriceChart";
|
||||
import { RecentTransfers } from "./_components/RecentTransfers";
|
||||
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
|
||||
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
|
||||
@@ -30,7 +29,6 @@ export async function ERC20PublicPage(props: {
|
||||
tokenDecimals,
|
||||
tokenInfoFromUB,
|
||||
functionSelectors,
|
||||
tokenPriceData,
|
||||
] = await Promise.all([
|
||||
getContractMetadata({
|
||||
contract: props.serverContract,
|
||||
@@ -41,14 +39,10 @@ export async function ERC20PublicPage(props: {
|
||||
}),
|
||||
fetchTokenInfoFromBridge({
|
||||
chainId: props.serverContract.chain.id,
|
||||
clientId: props.clientContract.client.clientId,
|
||||
client: props.serverContract.client,
|
||||
tokenAddress: props.serverContract.address,
|
||||
}),
|
||||
resolveFunctionSelectors(props.serverContract),
|
||||
getTokenPriceData({
|
||||
chainId: props.serverContract.chain.id,
|
||||
contractAddress: props.serverContract.address,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!contractMetadata.image && tokenInfoFromUB) {
|
||||
@@ -82,11 +76,15 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
|
||||
<PageHeader containerClassName="max-w-5xl" />
|
||||
<AssetPageView
|
||||
assetType="coin"
|
||||
chainId={props.chainMetadata.chainId}
|
||||
is_testnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-7xl" />
|
||||
|
||||
<div className="border-b">
|
||||
<div className="container max-w-5xl">
|
||||
<div className="border-b border-dashed">
|
||||
<div className="container max-w-7xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
@@ -105,57 +103,69 @@ export async function ERC20PublicPage(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
|
||||
<div className="flex grow flex-col gap-8">
|
||||
{showBuyEmbed && (
|
||||
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="text-border dark:text-border/70"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom right,white,transparent,transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="sm:w-[420px] z-10">
|
||||
<BuyEmbed
|
||||
chainMetadata={props.chainMetadata}
|
||||
claimConditionMeta={claimConditionMeta}
|
||||
clientContract={props.clientContract}
|
||||
tokenAddress={props.clientContract.address}
|
||||
tokenDecimals={tokenDecimals}
|
||||
tokenName={contractMetadata.name}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-8" />
|
||||
|
||||
{tokenPriceData ? (
|
||||
<>
|
||||
<TokenStats
|
||||
chainId={props.chainMetadata.chainId}
|
||||
contractAddress={props.clientContract.address}
|
||||
tokenPriceData={tokenPriceData}
|
||||
/>
|
||||
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
{tokenInfoFromUB && (
|
||||
<div className="container max-w-7xl mb-8">
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 p-6 border rounded-xl bg-card relative">
|
||||
<TokenInfoSection
|
||||
icon={DollarSignIcon}
|
||||
label="Price"
|
||||
value={
|
||||
tokenInfoFromUB.prices.USD
|
||||
? formatPrice(tokenInfoFromUB.prices.USD)
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TokenInfoSection
|
||||
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
|
||||
icon={TrendingUpIcon}
|
||||
label="Market Cap"
|
||||
value={
|
||||
tokenInfoFromUB.marketCapUsd
|
||||
? formatCompactUSD(tokenInfoFromUB.marketCapUsd)
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
|
||||
<TokenInfoSection
|
||||
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
|
||||
icon={BarChart3Icon}
|
||||
label="Volume (24h)"
|
||||
value={
|
||||
tokenInfoFromUB.volume24hUsd
|
||||
? formatCompactUSD(tokenInfoFromUB.volume24hUsd)
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBuyEmbed && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyEmbed
|
||||
chainMetadata={props.chainMetadata}
|
||||
claimConditionMeta={claimConditionMeta}
|
||||
clientContract={props.clientContract}
|
||||
tokenAddress={props.clientContract.address}
|
||||
tokenDecimals={tokenDecimals}
|
||||
tokenName={contractMetadata.name}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</GridPatternEmbedContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="container flex max-w-7xl grow flex-col pb-10">
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
@@ -188,10 +198,12 @@ function BuyEmbed(props: {
|
||||
}) {
|
||||
if (!props.claimConditionMeta) {
|
||||
return (
|
||||
<BuyTokenEmbed
|
||||
<BuyAndSwapEmbed
|
||||
chain={props.clientContract.chain}
|
||||
client={props.clientContract.client}
|
||||
tokenAddress={props.clientContract.address}
|
||||
buyAmount={undefined}
|
||||
pageType="asset"
|
||||
isTestnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -221,3 +233,61 @@ async function getActiveClaimConditionWithErrorHandler(
|
||||
}
|
||||
|
||||
type ActiveClaimCondition = Awaited<ReturnType<typeof getActiveClaimCondition>>;
|
||||
|
||||
function TokenInfoSection(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="flex mb-5">
|
||||
<div className="border rounded-full bg-background p-2">
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<dl className="space-y-1.5">
|
||||
<dt className="text-xs font-medium text-muted-foreground tracking-wider uppercase">
|
||||
{props.label}
|
||||
</dt>
|
||||
<dd className="text-3xl font-bold text-foreground tracking-tight">
|
||||
{props.value}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(value: number): string {
|
||||
if (value < 100) {
|
||||
return smallValueUSDFormatter.format(value);
|
||||
}
|
||||
return largeValueUSDFormatter.format(value);
|
||||
}
|
||||
|
||||
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 6,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
function formatCompactUSD(value: number): string {
|
||||
return compactValueUSDFormatter.format(value);
|
||||
}
|
||||
|
||||
+5
-1
@@ -18,7 +18,11 @@ export function NFTPublicPageLayout(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<AssetPageView assetType="nft" chainId={props.chainMetadata.chainId} />
|
||||
<AssetPageView
|
||||
assetType="nft"
|
||||
chainId={props.chainMetadata.chainId}
|
||||
is_testnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-8xl" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-8xl">
|
||||
|
||||
+4
@@ -120,6 +120,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
console.error(errorMessage);
|
||||
@@ -147,6 +148,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
assetType: "nft",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
props.onSuccess?.();
|
||||
@@ -163,6 +165,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -180,6 +183,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+4
@@ -77,6 +77,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -103,6 +104,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
assetType: "nft",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
props.onSuccess?.();
|
||||
@@ -115,6 +117,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -131,6 +134,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+17
-5
@@ -190,25 +190,37 @@ export async function generateContractLayoutMetadata(params: {
|
||||
.replace("Testnet", "")
|
||||
.trim();
|
||||
|
||||
const title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
|
||||
let title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
|
||||
let description = "";
|
||||
|
||||
if (isERC721 || isERC1155) {
|
||||
description = `View tokens, source code, transactions, balances, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}.`;
|
||||
} else if (isERC20) {
|
||||
description = `View ERC20 tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
|
||||
title = `${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} | Buy, Swap, Bridge & Price`;
|
||||
description = `Buy, swap & bridge ${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} with thirdweb Bridge. View contract address, holders, analytics, transactions and live price.`;
|
||||
} else {
|
||||
description = `View tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
|
||||
description = `View tokens, transactions, balances, source code, and analytics for the ${contractMetadata.name} smart contract on ${cleanedChainName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
description: description,
|
||||
title: title,
|
||||
openGraph: {
|
||||
description: description,
|
||||
title: title,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
const fallbackTitle = `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`;
|
||||
const fallbackDescription = `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`;
|
||||
|
||||
return {
|
||||
description: `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`,
|
||||
title: `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`,
|
||||
description: fallbackDescription,
|
||||
title: fallbackTitle,
|
||||
openGraph: {
|
||||
description: fallbackDescription,
|
||||
title: fallbackTitle,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -5,7 +5,7 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { ChainSupportedService } from "@/types/chain";
|
||||
import { ChainIcon } from "../../../components/server/chain-icon";
|
||||
import { getChainMetadata } from "../../../utils";
|
||||
import { getCustomChainMetadata } from "../../../utils";
|
||||
|
||||
type ChainListCardProps = {
|
||||
favoriteButton: JSX.Element | undefined;
|
||||
@@ -18,7 +18,7 @@ type ChainListCardProps = {
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export async function ChainListCard({
|
||||
export function ChainListCard({
|
||||
isDeprecated,
|
||||
chainId,
|
||||
chainName,
|
||||
@@ -28,7 +28,7 @@ export async function ChainListCard({
|
||||
favoriteButton,
|
||||
iconUrl,
|
||||
}: ChainListCardProps) {
|
||||
const chainMetadata = await getChainMetadata(chainId);
|
||||
const customChainMetadata = getCustomChainMetadata(chainId);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
@@ -90,9 +90,9 @@ export async function ChainListCard({
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{(isDeprecated || chainMetadata?.gasSponsored) && (
|
||||
{(isDeprecated || customChainMetadata?.gasSponsored) && (
|
||||
<div className="mt-5 flex gap-5 border-t pt-4">
|
||||
{!isDeprecated && chainMetadata?.gasSponsored && (
|
||||
{!isDeprecated && customChainMetadata?.gasSponsored && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TicketCheckIcon className="size-5 text-foreground" />
|
||||
<p className="text-sm">Gas Sponsored</p>
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { ChainSupportedService } from "@/types/chain";
|
||||
import { ChainIcon } from "../../../components/server/chain-icon";
|
||||
import { products } from "../../../components/server/products";
|
||||
import { getChainMetadata } from "../../../utils";
|
||||
import { getCustomChainMetadata } from "../../../utils";
|
||||
|
||||
type ChainListRowProps = {
|
||||
favoriteButton: JSX.Element | undefined;
|
||||
@@ -26,7 +26,7 @@ type ChainListRowProps = {
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export async function ChainListRow({
|
||||
export function ChainListRow({
|
||||
isDeprecated,
|
||||
chainId,
|
||||
chainName,
|
||||
@@ -36,7 +36,7 @@ export async function ChainListRow({
|
||||
favoriteButton,
|
||||
iconUrl,
|
||||
}: ChainListRowProps) {
|
||||
const chainMetadata = await getChainMetadata(chainId);
|
||||
const chainMetadata = getCustomChainMetadata(chainId);
|
||||
return (
|
||||
<TableRow className="hover:bg-accent/50" linkBox>
|
||||
{/* Name */}
|
||||
|
||||
@@ -914,9 +914,9 @@ const chainMetaRecord = {
|
||||
} satisfies Record<number, ExtraChainMetadata>;
|
||||
// END TEMPORARY
|
||||
|
||||
export async function getChainMetadata(
|
||||
export function getCustomChainMetadata(
|
||||
chainId: number,
|
||||
): Promise<(ExtraChainMetadata & { gasSponsored?: true }) | null> {
|
||||
): (ExtraChainMetadata & { gasSponsored?: true }) | null {
|
||||
// TODO: fetch this from the API
|
||||
if (chainId in chainMetaRecord) {
|
||||
return {
|
||||
|
||||
+5
@@ -50,6 +50,7 @@ import {
|
||||
} from "@/constants/addresses";
|
||||
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookie";
|
||||
import { ZERO_FEE_CHAINS } from "@/constants/fee-config";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import {
|
||||
useCustomFactoryAbi,
|
||||
useFunctionParamsFromABI,
|
||||
@@ -206,6 +207,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
|
||||
const activeAccount = useActiveAccount();
|
||||
const walletChain = useActiveWalletChain();
|
||||
const walletChainMetadata = useV5DashboardChain(walletChain?.id);
|
||||
|
||||
const { onError } = useTxNotifications(
|
||||
"Successfully deployed contract",
|
||||
"Failed to deploy contract",
|
||||
@@ -656,6 +659,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
chainId: walletChain.id,
|
||||
contractName: metadata.name,
|
||||
publisher: rewriteTwPublisher(metadata.publisher),
|
||||
is_testnet: walletChainMetadata?.testnet,
|
||||
});
|
||||
|
||||
deployStatusModal.nextStep();
|
||||
@@ -694,6 +698,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
contractName: metadata.name,
|
||||
errorMessage: parsedError,
|
||||
publisher: rewriteTwPublisher(metadata.publisher),
|
||||
is_testnet: walletChain.testnet,
|
||||
});
|
||||
|
||||
deployStatusModal.close();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
|
||||
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link className="flex items-center gap-2" href="/team">
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
<span className="hidden font-bold text-2xl tracking-tight lg:block">
|
||||
thirdweb
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { ActivityIcon, SearchIcon, TrendingUpIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TokensTable } from "./tokens-table";
|
||||
|
||||
const client = getClientThirdwebClient();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
type Filter =
|
||||
| { type: "filter"; value: "volume" | "market_cap" }
|
||||
| { type: "search"; value: string };
|
||||
|
||||
export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [chainId, setChainId] = useState(1);
|
||||
const [filter, _setFilter] = useState<Filter>({
|
||||
type: "filter",
|
||||
value: "volume",
|
||||
});
|
||||
|
||||
const setFilter = useCallback((filter: Filter) => {
|
||||
_setFilter(filter);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: [
|
||||
"tokens",
|
||||
{
|
||||
page,
|
||||
chainId,
|
||||
filter,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return Bridge.tokens({
|
||||
client: client,
|
||||
chainId: chainId,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
sortBy: filter.type === "filter" ? filter.value : undefined,
|
||||
query: filter.type === "search" ? filter.value : undefined,
|
||||
});
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pb-20 pt-8">
|
||||
<div className="container max-w-7xl">
|
||||
<div className="mb-4 flex gap-4 lg:gap-3 flex-col lg:flex-row">
|
||||
<BridgeNetworkSelector
|
||||
client={client}
|
||||
chainId={chainId}
|
||||
onChange={setChainId}
|
||||
className="rounded-xl bg-card lg:w-fit min-w-[280px]"
|
||||
popoverContentClassName="!w-[300px] rounded-xl overflow-hidden"
|
||||
chains={props.chains}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<SortButton
|
||||
label="Popular"
|
||||
onClick={() => {
|
||||
setFilter({ type: "filter", value: "market_cap" });
|
||||
}}
|
||||
isSelected={
|
||||
filter.type === "filter" && filter.value === "market_cap"
|
||||
}
|
||||
icon={ActivityIcon}
|
||||
/>
|
||||
<SortButton
|
||||
label="Trending"
|
||||
onClick={() => {
|
||||
setFilter({ type: "filter", value: "volume" });
|
||||
}}
|
||||
isSelected={filter.type === "filter" && filter.value === "volume"}
|
||||
icon={TrendingUpIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full lg:w-[420px] ml-auto">
|
||||
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search Tokens"
|
||||
value={filter.type === "search" ? filter.value : ""}
|
||||
className="rounded-xl bg-card flex-1 pl-9"
|
||||
onChange={(e) =>
|
||||
setFilter({ type: "search", value: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokensTable
|
||||
pageSize={pageSize}
|
||||
tokens={tokensQuery.data ?? []}
|
||||
isFetching={tokensQuery.isFetching}
|
||||
pagination={{
|
||||
onNext: () => setPage(page + 1),
|
||||
onPrevious: () => setPage(page - 1),
|
||||
nextDisabled: !!(
|
||||
tokensQuery.data && tokensQuery.data.length < pageSize
|
||||
),
|
||||
previousDisabled: page === 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.onClick}
|
||||
className={cn(
|
||||
"rounded-xl gap-2 bg-card",
|
||||
props.isSelected && "bg-accent border-active-border",
|
||||
)}
|
||||
>
|
||||
<props.icon className="size-3.5" />
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export function TokensTable(props: {
|
||||
tokens: Bridge.TokenWithPrices[];
|
||||
pageSize: number;
|
||||
isFetching: boolean;
|
||||
pagination: {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
nextDisabled: boolean;
|
||||
previousDisabled: boolean;
|
||||
};
|
||||
}) {
|
||||
const { tokens, isFetching } = props;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl overflow-hidden">
|
||||
<TableContainer className="border-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Market cap</TableHead>
|
||||
<TableHead>Volume (24h)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isFetching
|
||||
? new Array(props.pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: tokens.length > 0
|
||||
? tokens.map((token) => {
|
||||
const price = token.prices.USD;
|
||||
return (
|
||||
<TableRow
|
||||
key={`${token.chainId}-${token.address}`}
|
||||
className="hover:bg-accent/50"
|
||||
linkBox
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage
|
||||
src={token.iconUri ?? undefined}
|
||||
alt={token.symbol}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{token.symbol?.slice(0, 2)?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-medium leading-none text-base">
|
||||
{token.symbol}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${token.chainId}`
|
||||
: `/${token.chainId}/${token.address}`
|
||||
}
|
||||
prefetch={false}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{token.name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{price ? formatPrice(price) : "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token.marketCapUsd
|
||||
? formatUsdCompact(token.marketCapUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token.volume24hUsd
|
||||
? formatUsdCompact(token.volume24hUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{tokens.length === 0 && !isFetching && (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground w-full text-sm ">
|
||||
No tokens found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end p-4 gap-3 border-t bg-card">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full"
|
||||
size="sm"
|
||||
onClick={() => props.pagination.onPrevious()}
|
||||
disabled={props.pagination.previousDisabled || isFetching}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full"
|
||||
size="sm"
|
||||
onClick={() => props.pagination.onNext()}
|
||||
disabled={props.pagination.nextDisabled || isFetching}
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(value: number): string {
|
||||
if (value < 100) {
|
||||
return smallValueUSDFormatter.format(value);
|
||||
}
|
||||
return largeValueUSDFormatter.format(value);
|
||||
}
|
||||
|
||||
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 6,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
function formatUsdCompact(value: number): string {
|
||||
return compactValueUSDFormatter.format(value);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { TokenPage } from "./components/token-page";
|
||||
|
||||
const title = "Tokens | thirdweb";
|
||||
const description = "Discover and swap any tokens on any chain, instantly";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const chains = await getBridgeSupportedChains();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
<div className="border-b py-10 ">
|
||||
<div className="container max-w-7xl">
|
||||
<div className="flex mb-4">
|
||||
<div className="rounded-full bg-card p-2.5 border">
|
||||
<BringToFrontIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter mb-1">
|
||||
Discover and swap any tokens on any chain, instantly
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
85+ chains, 3200+ tokens and 9+ million routes supported
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TokenPage chains={chains} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getBridgeSupportedChains = unstable_cache(
|
||||
async () => {
|
||||
const chains = await Bridge.chains({ client: serverThirdwebClient });
|
||||
return chains;
|
||||
},
|
||||
["bridge-supported-chains"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { reportProductFeedback } from "@/analytics/report";
|
||||
import { NotificationsButton } from "@/components/notifications/notification-button";
|
||||
import { NavLink } from "@/components/ui/NavLink";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { AccountButton } from "./account-button.client";
|
||||
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
|
||||
@@ -31,6 +37,32 @@ export function SecondaryNav(props: {
|
||||
}
|
||||
|
||||
export function SecondaryNavLinks() {
|
||||
const [showFeedbackDropdown, setShowFeedbackDropdown] = useState(false);
|
||||
const [modalFeedback, setModalFeedback] = useState("");
|
||||
|
||||
const handleModalSubmit = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
// Report feedback to PostHog
|
||||
reportProductFeedback({
|
||||
feedback: modalFeedback,
|
||||
source: "desktop",
|
||||
});
|
||||
|
||||
// Show success notification
|
||||
toast.success("Feedback submitted successfully!", {
|
||||
description: "Thank you for your feedback. We'll review it shortly.",
|
||||
});
|
||||
|
||||
setModalFeedback("");
|
||||
setShowFeedbackDropdown(false);
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalFeedback("");
|
||||
setShowFeedbackDropdown(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<ResourcesDropdownButton />
|
||||
@@ -44,14 +76,77 @@ export function SecondaryNavLinks() {
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
href="https://feedback.thirdweb.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Feedback
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFeedbackDropdown(!showFeedbackDropdown)}
|
||||
className="text-muted-foreground text-sm hover:text-foreground border border-border px-3 py-1.5 rounded-full hover:bg-muted transition-colors"
|
||||
>
|
||||
Feedback
|
||||
</button>
|
||||
|
||||
{showFeedbackDropdown && (
|
||||
<div
|
||||
id="feedback-dropdown"
|
||||
role="dialog"
|
||||
aria-labelledby="feedback-heading"
|
||||
className="absolute top-full right-0 mt-2 bg-background border border-border rounded-2xl p-3 w-96 z-50"
|
||||
>
|
||||
<h2
|
||||
id="feedback-heading"
|
||||
className="text-foreground text-base font-sans mb-2"
|
||||
>
|
||||
Share your feedback with us:
|
||||
</h2>
|
||||
<form onSubmit={handleModalSubmit} className="contents">
|
||||
<label htmlFor="feedback-text" className="sr-only">
|
||||
Feedback
|
||||
</label>
|
||||
<textarea
|
||||
id="feedback-text"
|
||||
value={modalFeedback}
|
||||
onChange={(e) => setModalFeedback(e.target.value)}
|
||||
maxLength={1000}
|
||||
aria-describedby="feedback-help"
|
||||
className="w-full bg-background text-foreground rounded-lg p-4 min-h-[120px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans mb-4 text-sm"
|
||||
placeholder="Tell us what you think..."
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="text-muted-foreground text-xs font-sans">
|
||||
<div>Have a technical issue?</div>
|
||||
<div>
|
||||
<NavLink
|
||||
href="/team/~/support"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Contact support
|
||||
</NavLink>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleModalCancel}
|
||||
className="bg-transparent text-foreground px-4 py-1.5 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!modalFeedback.trim()}
|
||||
aria-disabled={!modalFeedback.trim()}
|
||||
className="bg-primary text-primary-foreground px-4 py-1.5 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
LogOutIcon,
|
||||
MenuIcon,
|
||||
MoonIcon,
|
||||
@@ -12,8 +14,11 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { reportProductFeedback } from "@/analytics/report";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NavLink } from "@/components/ui/NavLink";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { useEns } from "@/hooks/contract-hooks";
|
||||
@@ -36,6 +41,8 @@ export function MobileBurgerMenuButton(
|
||||
},
|
||||
) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [showFeedbackSection, setShowFeedbackSection] = useState(false);
|
||||
const [modalFeedback, setModalFeedback] = useState("");
|
||||
const { setTheme, theme } = useTheme();
|
||||
const ensQuery = useEns({
|
||||
addressOrEnsName:
|
||||
@@ -44,6 +51,29 @@ export function MobileBurgerMenuButton(
|
||||
});
|
||||
// const [isCMDSearchModalOpen, setIsCMDSearchModalOpen] = useState(false);
|
||||
|
||||
const handleModalSubmit = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
// Report feedback to PostHog
|
||||
reportProductFeedback({
|
||||
feedback: modalFeedback,
|
||||
source: "mobile",
|
||||
});
|
||||
|
||||
// Show success notification
|
||||
toast.success("Feedback submitted successfully!", {
|
||||
description: "Thank you for your feedback. We'll review it shortly.",
|
||||
});
|
||||
|
||||
setModalFeedback("");
|
||||
setShowFeedbackSection(false);
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalFeedback("");
|
||||
setShowFeedbackSection(false);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -71,7 +101,7 @@ export function MobileBurgerMenuButton(
|
||||
</Button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200">
|
||||
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200 overflow-y-auto">
|
||||
<Button
|
||||
className="!h-auto absolute top-4 right-4 p-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
@@ -191,14 +221,78 @@ export function MobileBurgerMenuButton(
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href="https://feedback.thirdweb.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Feedback
|
||||
</Link>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowFeedbackSection(!showFeedbackSection)}
|
||||
>
|
||||
<span>Feedback</span>
|
||||
{showFeedbackSection ? (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showFeedbackSection && (
|
||||
<div className="pl-0 pr-4 space-y-4 mb-6">
|
||||
<h3
|
||||
id="mobile-feedback-heading"
|
||||
className="text-sm font-medium text-foreground mb-2"
|
||||
>
|
||||
Share your feedback with us:
|
||||
</h3>
|
||||
<form onSubmit={handleModalSubmit} className="contents">
|
||||
<label htmlFor="mobile-feedback-text" className="sr-only">
|
||||
Feedback
|
||||
</label>
|
||||
<textarea
|
||||
id="mobile-feedback-text"
|
||||
value={modalFeedback}
|
||||
onChange={(e) => setModalFeedback(e.target.value)}
|
||||
maxLength={1000}
|
||||
aria-describedby="mobile-feedback-help"
|
||||
className="w-full bg-background text-foreground rounded-lg p-3 min-h-[100px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans text-sm"
|
||||
placeholder="Tell us what you think..."
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p
|
||||
id="mobile-feedback-help"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Have a technical issue?{" "}
|
||||
<NavLink
|
||||
href="/team/~/support"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Contact support
|
||||
</NavLink>
|
||||
.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleModalCancel}
|
||||
className="flex-1 bg-transparent text-foreground px-3 py-2 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!modalFeedback.trim()}
|
||||
aria-disabled={!modalFeedback.trim()}
|
||||
className="flex-1 bg-primary text-primary-foreground px-3 py-2 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { useId } from "react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
@@ -19,8 +18,8 @@ export const size = {
|
||||
export const contentType = "image/png";
|
||||
|
||||
const TWLogo: React.FC = () => {
|
||||
const cipId = useId();
|
||||
const linearGradientId = useId();
|
||||
const cipId = "og-tw-logo-clip-path";
|
||||
const linearGradientId = "og-tw-logo-linear-gradient";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
|
||||
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type ProjectService, SERVICES } from "@thirdweb-dev/service-utils";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { reportOnboardingCompleted } from "@/analytics/report";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { CreateProjectPrefillOptions } from "@/components/project/create-project-modal";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createProjectClient } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
|
||||
import { toArrFromList } from "@/utils/string";
|
||||
import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
|
||||
const ALL_PROJECT_SERVICES = SERVICES.filter(
|
||||
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
|
||||
);
|
||||
|
||||
export function CreateProjectFormOnboarding(props: {
|
||||
prefill?: CreateProjectPrefillOptions;
|
||||
enableNebulaServiceByDefault: boolean;
|
||||
teamSlug: string;
|
||||
teamId: string;
|
||||
}) {
|
||||
const [screen, setScreen] = useState<
|
||||
{ id: "create" } | { id: "api-details"; project: Project; secret: string }
|
||||
>({ id: "create" });
|
||||
return (
|
||||
<Card>
|
||||
<DynamicHeight>
|
||||
{screen.id === "create" && (
|
||||
<CreateProjectForm
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
const vaultTokens = await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
console.error(
|
||||
"Failed to create vault account and access token",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const managementAccessToken =
|
||||
vaultTokens.managementToken?.accessToken;
|
||||
|
||||
if (!managementAccessToken) {
|
||||
throw new Error(
|
||||
"Missing management access token for project wallet",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
};
|
||||
}}
|
||||
enableNebulaServiceByDefault={props.enableNebulaServiceByDefault}
|
||||
onProjectCreated={(params) => {
|
||||
setScreen({
|
||||
id: "api-details",
|
||||
project: params.project,
|
||||
secret: params.secret,
|
||||
});
|
||||
}}
|
||||
prefill={props.prefill}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "api-details" && (
|
||||
<CreatedProjectDetails
|
||||
onComplete={() => {
|
||||
setScreen({ id: "create" });
|
||||
}}
|
||||
project={screen.project}
|
||||
secret={screen.secret}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
)}
|
||||
</DynamicHeight>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const createProjectFormSchema = z.object({
|
||||
domains: projectDomainsSchema,
|
||||
name: projectNameSchema,
|
||||
});
|
||||
|
||||
type CreateProjectFormSchema = z.infer<typeof createProjectFormSchema>;
|
||||
|
||||
function CreateProjectForm(props: {
|
||||
createProject: (param: Partial<Project>) => Promise<{
|
||||
project: Project;
|
||||
secret: string;
|
||||
}>;
|
||||
prefill?: CreateProjectPrefillOptions;
|
||||
enableNebulaServiceByDefault: boolean;
|
||||
onProjectCreated: (params: { project: Project; secret: string }) => void;
|
||||
}) {
|
||||
const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">();
|
||||
|
||||
const createProject = useMutation({
|
||||
mutationFn: props.createProject,
|
||||
});
|
||||
|
||||
const form = useForm<CreateProjectFormSchema>({
|
||||
defaultValues: {
|
||||
domains: props.prefill?.domains || "",
|
||||
name: props.prefill?.name || "",
|
||||
},
|
||||
resolver: zodResolver(createProjectFormSchema),
|
||||
});
|
||||
|
||||
function handleAPICreation(values: { name: string; domains: string }) {
|
||||
const servicesToEnableByDefault = props.enableNebulaServiceByDefault
|
||||
? ALL_PROJECT_SERVICES
|
||||
: ALL_PROJECT_SERVICES.filter((srv) => srv.name !== "nebula");
|
||||
|
||||
const formattedValues: Partial<Project> = {
|
||||
domains: toArrFromList(values.domains),
|
||||
name: values.name,
|
||||
// enable all services
|
||||
services: servicesToEnableByDefault.map((srv) => {
|
||||
if (srv.name === "storage") {
|
||||
return {
|
||||
actions: srv.actions.map((sa) => sa.name),
|
||||
name: srv.name,
|
||||
} satisfies ProjectService;
|
||||
}
|
||||
|
||||
if (srv.name === "pay") {
|
||||
return {
|
||||
actions: [],
|
||||
name: "pay",
|
||||
payoutAddress: null,
|
||||
} satisfies ProjectService;
|
||||
}
|
||||
|
||||
return {
|
||||
actions: [],
|
||||
name: srv.name,
|
||||
} satisfies ProjectService;
|
||||
}),
|
||||
};
|
||||
|
||||
createProject.mutate(formattedValues, {
|
||||
onError: () => {
|
||||
toast.error("Failed to create a project");
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
props.onProjectCreated(data);
|
||||
toast.success("Project created successfully");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
if (!values.domains) {
|
||||
setShowAlert("no-domain");
|
||||
} else if (values.domains === "*") {
|
||||
setShowAlert("any-domain");
|
||||
} else {
|
||||
handleAPICreation({
|
||||
domains: values.domains,
|
||||
name: values.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (showAlert) {
|
||||
return (
|
||||
<DomainsAlert
|
||||
isCreating={createProject.isPending}
|
||||
onGoBack={() => setShowAlert(undefined)}
|
||||
onProceed={() => {
|
||||
handleAPICreation({
|
||||
domains: form.getValues("domains"),
|
||||
name: form.getValues("name"),
|
||||
});
|
||||
}}
|
||||
type={showAlert}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<header className="mb-4">
|
||||
<h2 className="text-2xl">Create Project</h2>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-card"
|
||||
placeholder="My Project"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<CheckboxWithLabel className="absolute top-0 right-0 text-foreground">
|
||||
<Checkbox
|
||||
checked={form.watch("domains") === "*"}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue("domains", checked ? "*" : "", {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Allow all domains
|
||||
</CheckboxWithLabel>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domains"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Allowed Domains</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="bg-card"
|
||||
placeholder="thirdweb.com, *.example.com, localhost:3000"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div className="pt-1">
|
||||
<FormDescription className="mb-2 text-sm leading-relaxed">
|
||||
Prevent third-parties from using your Client ID by
|
||||
restricting access to allowed domains. Highly
|
||||
recommended for frontend applications.
|
||||
</FormDescription>
|
||||
|
||||
<ul className="flex list-disc flex-col gap-2 pl-3 text-muted-foreground text-sm [&>li]:pl-1 ">
|
||||
<li>
|
||||
Authorize all domains with{" "}
|
||||
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
|
||||
*
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
Example:{" "}
|
||||
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
|
||||
*.thirdweb.com
|
||||
</span>{" "}
|
||||
accepts all{" "}
|
||||
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
|
||||
.thirdweb.com
|
||||
</span>{" "}
|
||||
sites
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Authorize local URLs with{" "}
|
||||
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
|
||||
{"localhost:<port>"}
|
||||
</span>
|
||||
</li>
|
||||
<li>Separate domains by commas or new lines</li>
|
||||
</ul>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex gap-4 border-border border-t p-6 md:gap-2">
|
||||
<Button
|
||||
className="min-w-28 gap-2 ml-auto"
|
||||
disabled={createProject.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{createProject.isPending && <Spinner className="size-4" />}
|
||||
Create Projecct
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainsAlert(props: {
|
||||
type: "no-domain" | "any-domain";
|
||||
isCreating: boolean;
|
||||
onProceed: () => void;
|
||||
onGoBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 p-6">
|
||||
<header>
|
||||
<h2 className="text-2xl">
|
||||
{props.type === "no-domain" && "No Domains Configured"}
|
||||
{props.type === "any-domain" && "Unrestricted Web Access"}
|
||||
</h2>
|
||||
<p>
|
||||
{props.type === "no-domain" &&
|
||||
"This will deny requests from all origins, rendering the key unusable in frontend applications. Proceed only if you intend to use this key in server or native apps environments."}
|
||||
{props.type === "any-domain" &&
|
||||
"Requests from all origins will be authorized. Your key can be used by any website without restriction"}
|
||||
</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<footer className="!justify-between flex gap-4 border-border border-t bg-card p-6">
|
||||
<Button
|
||||
className="min-w-28 gap-2"
|
||||
onClick={props.onGoBack}
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Update Domains
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="min-w-28 gap-2"
|
||||
disabled={props.isCreating}
|
||||
onClick={props.onProceed}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{props.isCreating && <Spinner className="size-4" />}
|
||||
Proceed
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatedProjectDetails(props: {
|
||||
project: Project;
|
||||
secret: string;
|
||||
onComplete: () => void;
|
||||
teamSlug: string | undefined;
|
||||
}) {
|
||||
const [secretStored, setSecretStored] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const clientId = props.project.publishableKey;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-6">
|
||||
<header>
|
||||
<h2 className="text-2xl">{props.project.name}</h2>
|
||||
</header>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<section>
|
||||
<h3>Client ID</h3>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
Identifies your application.
|
||||
</p>
|
||||
|
||||
<CopyTextButton
|
||||
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
|
||||
copyIconPosition="right"
|
||||
textToCopy={clientId}
|
||||
textToShow={clientId}
|
||||
tooltip="Copy Client ID"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<section>
|
||||
<h3>Secret Key</h3>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
Identifies and authenticates your application from a backend.
|
||||
</p>
|
||||
|
||||
<CopyTextButton
|
||||
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.secret || ""}
|
||||
textToShow={props.secret || ""}
|
||||
tooltip="Copy Secret Key"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Do not share or expose your secret key</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mb-5">
|
||||
Secret keys cannot be recovered. If you lose your secret key, you
|
||||
will need to rotate the secret key or create a new Project.
|
||||
</div>
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
checked={secretStored}
|
||||
onCheckedChange={(v) => {
|
||||
setSecretStored(!!v);
|
||||
}}
|
||||
/>
|
||||
I confirm that I've securely stored my secret key
|
||||
</CheckboxWithLabel>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<footer className="flex border-border border-t bg-card p-6">
|
||||
{props.teamSlug && (
|
||||
<Button
|
||||
className="min-w-28 gap-2 ml-auto"
|
||||
disabled={!secretStored}
|
||||
onClick={() => {
|
||||
reportOnboardingCompleted();
|
||||
router.push(`/team/${props.teamSlug}/${props.project.slug}`);
|
||||
}}
|
||||
>
|
||||
View Project
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
|
||||
import { CreateProjectFormOnboarding } from "./_components/create-project-form";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [team, authToken] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!team || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamOnboardingLayout currentStep={4}>
|
||||
<CreateProjectFormOnboarding
|
||||
enableNebulaServiceByDefault={true}
|
||||
teamSlug={team.slug}
|
||||
teamId={team.id}
|
||||
/>
|
||||
</TeamOnboardingLayout>
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO: fix this later
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
|
||||
export function GrowthPlanCheckout(props: {
|
||||
team: Team;
|
||||
fetchClientSecret: (team: Team) => Promise<string>;
|
||||
}) {
|
||||
return (
|
||||
<div id="checkout">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
fetchClientSecret: () => props.fetchClientSecret(props.team),
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import {
|
||||
fetchClientSecret,
|
||||
getStripeSessionById,
|
||||
} from "@/actions/stripe-actions";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug, type Team } from "@/api/team/get-team";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { pollWithTimeout } from "@/utils/pollWithTimeout";
|
||||
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
|
||||
import { PlanSelector } from "./_components/plan-selector";
|
||||
import { GrowthPlanCheckout } from "./_components/stripe-checkout";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
searchParams: Promise<{ session_id?: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
const [team, authToken] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getAuthToken(),
|
||||
@@ -17,18 +27,47 @@ export default async function Page(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
async function getTeam() {
|
||||
"use server";
|
||||
const resolvedTeam = await getTeamBySlug(params.team_slug);
|
||||
if (!resolvedTeam) {
|
||||
return team as Team;
|
||||
// IF we have a session id, check if the session was completed successfully
|
||||
let error: string | null = null;
|
||||
if (searchParams.session_id) {
|
||||
const session = await getStripeSessionById(searchParams.session_id);
|
||||
|
||||
switch (session.status) {
|
||||
case "complete": {
|
||||
// poll the team until it shows as "growth" plan (to handle race condition with stripe webhook)
|
||||
await pollWithTimeout({
|
||||
shouldStop: async () => {
|
||||
const refreshedTeam = await getTeamBySlug(team.slug);
|
||||
return refreshedTeam?.billingPlan === "growth";
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
redirect(`/get-started/team/${team.slug}/add-members`);
|
||||
break;
|
||||
}
|
||||
case "open": {
|
||||
error =
|
||||
"Something went wrong when trying to process your payment method. Please try again.";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
error = "Please try again later.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
return resolvedTeam;
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamOnboardingLayout currentStep={2}>
|
||||
<PlanSelector getTeam={getTeam} team={team} />
|
||||
<div className="rounded-lg border bg-card overflow-hidden py-4 md:py-8">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to proces subscription</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<GrowthPlanCheckout team={team} fetchClientSecret={fetchClientSecret} />
|
||||
</div>
|
||||
</TeamOnboardingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -24,6 +25,9 @@ export function AppRouterProviders(props: {
|
||||
children: React.ReactNode;
|
||||
autoConnect: boolean;
|
||||
}) {
|
||||
// get the current pathname
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -36,6 +40,9 @@ export function AppRouterProviders(props: {
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
forcedTheme={
|
||||
pathname.startsWith("/get-started") ? "dark" : undefined
|
||||
}
|
||||
>
|
||||
<Toaster richColors />
|
||||
<SanctionedAddressesChecker>
|
||||
|
||||
@@ -103,15 +103,12 @@ export default async function TeamLayout(props: {
|
||||
>
|
||||
{props.children}
|
||||
</TeamSidebarLayout>
|
||||
<div className="fixed right-4 bottom-4 z-50">
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
label="Get Help"
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
+29
-22
@@ -25,6 +25,34 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
|
||||
import { searchParams } from "../search-params";
|
||||
|
||||
function getStatusBadge(invoice: Stripe.Invoice) {
|
||||
switch (invoice.status) {
|
||||
case "paid":
|
||||
return <Badge variant="success">Paid</Badge>;
|
||||
case "open":
|
||||
// we treat "uncollectible" as unpaid
|
||||
case "uncollectible": {
|
||||
// if the invoice has a due date, use that, otherwise use the effective at date (which is the date the invoice was created)
|
||||
const effectiveDueDate = invoice.due_date || invoice.effective_at;
|
||||
|
||||
// if the invoice due date is in the past, we want to display it as past due
|
||||
if (effectiveDueDate && effectiveDueDate * 1000 < Date.now()) {
|
||||
return <Badge variant="destructive">Past Due</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">Open</Badge>;
|
||||
}
|
||||
case "void":
|
||||
return <Badge variant="secondary">Void</Badge>;
|
||||
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function isInvoicePayable(invoice: Stripe.Invoice) {
|
||||
return invoice.status === "open" || invoice.status === "uncollectible";
|
||||
}
|
||||
|
||||
export function BillingHistory(props: {
|
||||
teamSlug: string;
|
||||
invoices: Stripe.Invoice[];
|
||||
@@ -57,27 +85,6 @@ export function BillingHistory(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (invoice: Stripe.Invoice) => {
|
||||
switch (invoice.status) {
|
||||
case "paid":
|
||||
return <Badge variant="success">Paid</Badge>;
|
||||
case "open":
|
||||
// we treate "uncollectible" as unpaid
|
||||
case "uncollectible": {
|
||||
// if the invoice due date is in the past, we want to display it as past due
|
||||
if (invoice.due_date && invoice.due_date * 1000 < Date.now()) {
|
||||
return <Badge variant="destructive">Past Due</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">Open</Badge>;
|
||||
}
|
||||
case "void":
|
||||
return <Badge variant="secondary">Void</Badge>;
|
||||
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (props.invoices.length === 0) {
|
||||
if (props.status === "open") {
|
||||
return (
|
||||
@@ -126,7 +133,7 @@ export function BillingHistory(props: {
|
||||
<TableCell>{getStatusBadge(invoice)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{invoice.status === "open" && (
|
||||
{isInvoicePayable(invoice) && (
|
||||
<>
|
||||
{/* always show the crypto payment button */}
|
||||
<ToolTipLabel
|
||||
|
||||
+97
-68
@@ -34,7 +34,7 @@ interface SupportCaseDetailsProps {
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
const [replyMessage, setReplyMessage] = useState("");
|
||||
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||
const [localMessages, setLocalMessages] = useState(ticket.messages || []);
|
||||
@@ -42,6 +42,15 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
// rating/feedback
|
||||
const [rating, setRating] = useState(0);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
// non-blocking warning when status check fails
|
||||
const [statusCheckFailed, setStatusCheckFailed] = useState(false);
|
||||
|
||||
// Helper function to handle status check errors consistently
|
||||
const handleStatusCheckError = (_error: unknown) => {
|
||||
// Set degraded state for warning banner
|
||||
setStatusCheckFailed(true);
|
||||
return;
|
||||
};
|
||||
|
||||
// Check if feedback has already been submitted for this ticket
|
||||
const feedbackStatusQuery = useQuery({
|
||||
@@ -49,18 +58,25 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
queryFn: async () => {
|
||||
const result = await checkFeedbackStatus(ticket.id);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
handleStatusCheckError(result.error);
|
||||
return false; // Non-blocking: allow feedback submission despite status check failure
|
||||
}
|
||||
|
||||
// Clear degraded state on success
|
||||
if (statusCheckFailed) setStatusCheckFailed(false);
|
||||
|
||||
return result.hasFeedback;
|
||||
},
|
||||
enabled: ticket.status === "closed",
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1, // Reduce retries since we want non-blocking behavior
|
||||
});
|
||||
|
||||
const feedbackSubmitted = feedbackStatusQuery.data ?? false;
|
||||
const isLoading = feedbackStatusQuery.isLoading;
|
||||
const hasError = feedbackStatusQuery.isError;
|
||||
// query never throws; use local degraded flag for the inline warning
|
||||
const hasError = statusCheckFailed;
|
||||
|
||||
const handleStarClick = (starIndex: number) => {
|
||||
setRating(starIndex + 1);
|
||||
@@ -70,29 +86,36 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
const submitFeedbackMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (rating === 0) {
|
||||
throw new Error("Please select a rating");
|
||||
const error = "Please select a rating";
|
||||
throw new Error(error);
|
||||
}
|
||||
const result = await submitSupportFeedback({
|
||||
rating,
|
||||
feedback,
|
||||
ticketId: ticket.id,
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
// Add more specific error information
|
||||
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Thank you for your feedback!");
|
||||
|
||||
setRating(0);
|
||||
setFeedback("");
|
||||
|
||||
// mark as submitted immediately
|
||||
queryClient.setQueryData(["feedbackStatus", ticket.id], true);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("Failed to submit feedback:", err);
|
||||
const msg = err instanceof Error ? err.message : String(err ?? "");
|
||||
let message = "Failed to submit feedback. Please try again.";
|
||||
|
||||
if (/network|fetch/i.test(msg)) {
|
||||
message = "Network error. Please check your connection and try again.";
|
||||
} else if (
|
||||
@@ -102,6 +125,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
} else if (/API Server error/i.test(msg)) {
|
||||
message = "Server error. Please try again later.";
|
||||
}
|
||||
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
@@ -157,8 +181,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
}
|
||||
|
||||
setReplyMessage("");
|
||||
} catch (error) {
|
||||
console.error("Failed to send reply:", error);
|
||||
} catch {
|
||||
toast.error("Failed to send Message. Please try again.");
|
||||
|
||||
// Remove the optimistic message on error
|
||||
@@ -233,73 +256,77 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.status === "closed" && !isLoading && !feedbackSubmitted && (
|
||||
<div className="border-t p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This ticket is closed. Give us a quick rating to let us know how
|
||||
we did!
|
||||
</p>
|
||||
{hasError && (
|
||||
<p className="text-destructive text-xs mt-2">
|
||||
Couldn't verify prior feedback right now — you can still submit
|
||||
a rating.
|
||||
{ticket.status === "closed" &&
|
||||
!isLoading &&
|
||||
(!feedbackSubmitted || hasError) && (
|
||||
<div className="border-t p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This ticket is closed. Give us a quick rating to let us know how
|
||||
we did!
|
||||
</p>
|
||||
)}
|
||||
{hasError && (
|
||||
<div className="mt-2">
|
||||
<p className="text-destructive text-xs">
|
||||
Couldn't verify prior feedback right now — you can still
|
||||
submit a rating.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mb-6 mt-4">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={`star-${starValue}`}
|
||||
<div className="flex gap-2 mb-6 mt-4">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={`star-${starValue}`}
|
||||
type="button"
|
||||
onClick={() => handleStarClick(starValue - 1)}
|
||||
className="transition-colors"
|
||||
aria-label={`Rate ${starValue} out of 5 stars`}
|
||||
>
|
||||
<StarIcon
|
||||
size={32}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
starValue <= rating
|
||||
? "text-pink-500 fill-current stroke-current"
|
||||
: "text-muted-foreground fill-current stroke-current",
|
||||
"hover:text-pink-500",
|
||||
)}
|
||||
strokeWidth={starValue <= rating ? 2 : 1}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<AutoResizeTextarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="Optional: Tell us how we can improve."
|
||||
maxLength={1000}
|
||||
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleStarClick(starValue - 1)}
|
||||
className="transition-colors"
|
||||
aria-label={`Rate ${starValue} out of 5 stars`}
|
||||
onClick={handleSendFeedback}
|
||||
disabled={submitFeedbackMutation.isPending || rating === 0}
|
||||
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<StarIcon
|
||||
size={32}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
starValue <= rating
|
||||
? "text-pink-500 fill-current stroke-current"
|
||||
: "text-muted-foreground fill-current stroke-current",
|
||||
"hover:text-pink-500",
|
||||
)}
|
||||
strokeWidth={starValue <= rating ? 2 : 1}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{submitFeedbackMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="size-4 mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Send Feedback"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<AutoResizeTextarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="Optional: Tell us how we can improve."
|
||||
maxLength={1000}
|
||||
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendFeedback}
|
||||
disabled={submitFeedbackMutation.isPending || rating === 0}
|
||||
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{submitFeedbackMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="size-4 mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Send Feedback"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.status === "closed" && feedbackSubmitted && (
|
||||
{ticket.status === "closed" && feedbackSubmitted && !hasError && (
|
||||
<div className="border-t p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Thank you for your feedback! We appreciate your input and will use
|
||||
@@ -455,3 +482,5 @@ function TicketMessage(props: { message: SupportMessage }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SupportCaseDetails };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user