Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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=""
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -474,6 +474,11 @@ async function redirects() {
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/routes",
|
||||
destination: "/tokens",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import "server-only";
|
||||
|
||||
import { 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,59 @@ 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");
|
||||
}
|
||||
|
||||
// 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 (!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;
|
||||
}
|
||||
|
||||
@@ -81,33 +81,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 +134,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
|
||||
@@ -240,7 +225,7 @@ type AssetContractType =
|
||||
*/
|
||||
export function reportAssetBuySuccessful(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy successful", {
|
||||
@@ -250,9 +235,113 @@ export function reportAssetBuySuccessful(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 failed asset purchases from the token page
|
||||
* - 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 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,7 +349,7 @@ export function reportAssetBuySuccessful(properties: {
|
||||
*/
|
||||
export function reportAssetBuyFailed(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
error: string;
|
||||
}) {
|
||||
@@ -272,6 +361,26 @@ export function reportAssetBuyFailed(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;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy cancelled", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
}
|
||||
|
||||
// Assets Landing Page ----------------------------
|
||||
|
||||
/**
|
||||
@@ -477,6 +586,17 @@ export function reportAssetPageview(properties: {
|
||||
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 }) {
|
||||
posthog.capture("chain pageview", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the usage of fund wallet modal
|
||||
@@ -547,3 +667,27 @@ 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", {
|
||||
feedback: properties.feedback,
|
||||
source: properties.source,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"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;
|
||||
}) {
|
||||
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">
|
||||
<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 !w-full !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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
theme={themeObj}
|
||||
tokenAddress={props.tokenAddress as `0x${string}`}
|
||||
paymentMethods={["card"]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "swap" && (
|
||||
<SwapWidget
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none !w-full"
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GridPattern } from "@/components/ui/background-patterns";
|
||||
|
||||
export function GridPatternEmbedContainer(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-xl 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="sm:w-[420px] z-10">{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"
|
||||
|
||||
@@ -44,11 +44,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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+11
-16
@@ -1,24 +1,19 @@
|
||||
"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
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
import { reportChainPageview } from "@/analytics/report";
|
||||
import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
|
||||
export function ChainPageView(props: { chainId: number }) {
|
||||
useEffectOnce(() => {
|
||||
reportChainPageview({
|
||||
chainId: props.chainId,
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
|
||||
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
|
||||
@@ -71,6 +72,7 @@ export default async function ChainPageLayout(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<ChainPageView chainId={chain.chainId} />
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
|
||||
+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
|
||||
|
||||
@@ -44,7 +44,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 */}
|
||||
|
||||
-1
@@ -67,7 +67,6 @@ export async function getContractUniqueWalletAnalytics(params: {
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
console.log("wallet analytics json", json);
|
||||
const aggregations = Object.values(json.aggregations[0]);
|
||||
|
||||
const returnValue: TransactionAnalyticsEntry[] = [];
|
||||
|
||||
+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,
|
||||
|
||||
+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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+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}
|
||||
|
||||
+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",
|
||||
}}
|
||||
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
export const mapChainIdToDexScreenerChainSlug = {
|
||||
8453: "base",
|
||||
1: "ethereum",
|
||||
56: "bsc",
|
||||
369: "pulsechain",
|
||||
137: "polygon",
|
||||
2741: "abstract",
|
||||
43114: "avalanche",
|
||||
999: "hyperliquid",
|
||||
480: "worldchain",
|
||||
42161: "arbitrum",
|
||||
388: "cronos",
|
||||
59144: "linea",
|
||||
1514: "story",
|
||||
397: "near",
|
||||
295: "hedera",
|
||||
146: "sonic",
|
||||
10: "optimism",
|
||||
80094: "berachain",
|
||||
57073: "ink",
|
||||
130: "unichain",
|
||||
5000: "mantle",
|
||||
324: "zksync",
|
||||
466: "apechain",
|
||||
1116: "core",
|
||||
250: "fantom",
|
||||
1868: "soneium",
|
||||
81457: "blast",
|
||||
2000: "dogechain",
|
||||
14: "flare",
|
||||
2040: "vana",
|
||||
4337: "beam",
|
||||
109: "shibarium",
|
||||
747: "flowevm",
|
||||
1088: "metis",
|
||||
1030: "conflux",
|
||||
43113: "avalanchedfk",
|
||||
534352: "scroll",
|
||||
747474: "katana",
|
||||
42220: "celo",
|
||||
1284: "moonbeam",
|
||||
4200: "merlinchain",
|
||||
2222: "kava",
|
||||
39797: "energi",
|
||||
34443: "mode",
|
||||
252: "fraxtal",
|
||||
48900: "zircuit",
|
||||
20: "elastos",
|
||||
100: "gnosischain",
|
||||
204: "opbnb",
|
||||
169: "manta",
|
||||
1313161554: "aurora",
|
||||
3073: "movement",
|
||||
4689: "iotex",
|
||||
23294: "oasissapphire",
|
||||
6001: "bouncebit",
|
||||
42170: "arbitrumnova",
|
||||
1101: "polygonzkevm",
|
||||
40: "telos",
|
||||
592: "astar",
|
||||
42262: "oasisemerald",
|
||||
1285: "moonriver",
|
||||
245022934: "neonevm",
|
||||
7777777: "zora",
|
||||
122: "fuse",
|
||||
321: "kcc",
|
||||
1234: "stepnetwork",
|
||||
106: "velas",
|
||||
167000: "taiko",
|
||||
288: "boba",
|
||||
42766: "zkfair",
|
||||
32520: "bitgert",
|
||||
82: "meter",
|
||||
} as const;
|
||||
|
||||
export type DexScreenerChainSlug =
|
||||
(typeof mapChainIdToDexScreenerChainSlug)[keyof typeof mapChainIdToDexScreenerChainSlug];
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
import { Spinner } from "@workspace/ui/components/spinner";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo } from "react";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import type { DexScreenerChainSlug } from "./dex-screener-chains";
|
||||
|
||||
function DexScreenerIframe(props: {
|
||||
chain: DexScreenerChainSlug;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const iframeUrl = useMemo(() => {
|
||||
const resolvedTheme = theme === "light" ? "light" : "dark";
|
||||
const url = new URL("https://dexscreener.com");
|
||||
url.pathname = `${props.chain}/${props.contractAddress}`;
|
||||
url.searchParams.set("embed", "1");
|
||||
url.searchParams.set("loadChartSettings", "0");
|
||||
url.searchParams.set("chartTheme", resolvedTheme);
|
||||
url.searchParams.set("theme", resolvedTheme);
|
||||
url.searchParams.set("trades", "1");
|
||||
url.searchParams.set("chartStyle", "1");
|
||||
url.searchParams.set("chartLeftToolbar", "0");
|
||||
url.searchParams.set("chartType", "usd");
|
||||
url.searchParams.set("interval", "15");
|
||||
url.searchParams.set("chartDefaultOnMobile", "1");
|
||||
return url.toString();
|
||||
}, [theme, props.chain, props.contractAddress]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-full h-[500px] lg:h-[1000px] rounded-lg overflow-hidden border bg-card"
|
||||
src={iframeUrl}
|
||||
title="DexScreener"
|
||||
></iframe>
|
||||
);
|
||||
}
|
||||
|
||||
export function DexScreener(props: {
|
||||
chain: DexScreenerChainSlug;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
return (
|
||||
<ClientOnly
|
||||
ssr={
|
||||
<div className="w-full h-[500px] lg:h-[1000px] rounded-lg border bg-card items-center justify-center flex">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DexScreenerIframe
|
||||
chain={props.chain}
|
||||
contractAddress={props.contractAddress}
|
||||
/>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
+112
-44
@@ -1,9 +1,11 @@
|
||||
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";
|
||||
@@ -12,9 +14,6 @@ import { PageHeader } from "../_components/PageHeader";
|
||||
import { ContractHeaderUI } from "./_components/ContractHeader";
|
||||
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
|
||||
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
|
||||
import { DexScreener } from "./_components/dex-screener";
|
||||
import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains";
|
||||
import { BuyTokenEmbed } from "./_components/PayEmbedSection";
|
||||
import { RecentTransfers } from "./_components/RecentTransfers";
|
||||
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
|
||||
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
|
||||
@@ -40,7 +39,7 @@ 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),
|
||||
@@ -80,7 +79,7 @@ export async function ERC20PublicPage(props: {
|
||||
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
|
||||
<PageHeader containerClassName="max-w-7xl" />
|
||||
|
||||
<div className="border-b">
|
||||
<div className="border-b border-dashed">
|
||||
<div className="container max-w-7xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
@@ -102,47 +101,57 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
{showBuyEmbed && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<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)",
|
||||
}}
|
||||
{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"
|
||||
}
|
||||
/>
|
||||
<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>{" "}
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && (
|
||||
{showBuyEmbed && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<DexScreener
|
||||
chain={
|
||||
mapChainIdToDexScreenerChainSlug[
|
||||
props.chainMetadata
|
||||
.chainId as keyof typeof mapChainIdToDexScreenerChainSlug
|
||||
]
|
||||
}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyEmbed
|
||||
chainMetadata={props.chainMetadata}
|
||||
claimConditionMeta={claimConditionMeta}
|
||||
clientContract={props.clientContract}
|
||||
tokenAddress={props.clientContract.address}
|
||||
tokenDecimals={tokenDecimals}
|
||||
tokenName={contractMetadata.name}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</GridPatternEmbedContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -185,10 +194,11 @@ 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -218,3 +228,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);
|
||||
}
|
||||
|
||||
@@ -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,130 @@
|
||||
"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 { 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;
|
||||
|
||||
export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [chainId, setChainId] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: [
|
||||
"tokens",
|
||||
{
|
||||
page,
|
||||
chainId,
|
||||
sortBy,
|
||||
search,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return Bridge.tokens({
|
||||
client: client,
|
||||
chainId: chainId,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
sortBy: search ? undefined : sortBy,
|
||||
query: search ? search : 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={() => {
|
||||
setSortBy("market_cap");
|
||||
setSearch("");
|
||||
}}
|
||||
isSelected={sortBy === "market_cap" && !search}
|
||||
icon={ActivityIcon}
|
||||
/>
|
||||
<SortButton
|
||||
label="Trending"
|
||||
onClick={() => {
|
||||
setSortBy("volume");
|
||||
setSearch("");
|
||||
}}
|
||||
isSelected={sortBy === "volume" && !search}
|
||||
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={search}
|
||||
className="rounded-xl bg-card flex-1 pl-9"
|
||||
onChange={(e) => setSearch(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,61 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
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>
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href="https://thirdweb.com/monetize/bridge"
|
||||
target="_blank"
|
||||
>
|
||||
Powered by thirdweb bridge
|
||||
</Link>
|
||||
</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
|
||||
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
"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);
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
console.error(
|
||||
"Failed to create vault account and access token",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
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>
|
||||
);
|
||||
|
||||
+34
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, InfoIcon } from "lucide-react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
@@ -24,6 +24,7 @@ export function EmptyStateChatPageContent(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden py-10 lg:py-16">
|
||||
<AIUsageBanner />
|
||||
{props.showAurora && (
|
||||
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--primary-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--primary-foreground)/10%)]" />
|
||||
)}
|
||||
@@ -165,3 +166,35 @@ const Aurora: React.FC<AuroraProps> = ({ className }) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function AIUsageBanner() {
|
||||
return (
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex justify-center px-4 pt-4">
|
||||
<div className="max-w-4xl">
|
||||
<div className="relative overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-blue-50/50 p-3 shadow-sm dark:border-blue-800 dark:from-blue-950/50 dark:to-blue-950/20">
|
||||
<div className="relative flex items-center gap-3 px-2">
|
||||
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<InfoIcon className="size-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
thirdweb AI usage is billed based on number of tokens used. See
|
||||
the{" "}
|
||||
<a
|
||||
href="https://thirdweb.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-blue-700 underline decoration-blue-300 underline-offset-2 transition-colors hover:text-blue-800 hover:decoration-blue-500 dark:text-blue-300 dark:decoration-blue-600 dark:hover:text-blue-200 dark:hover:decoration-blue-400"
|
||||
>
|
||||
pricing page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-37
@@ -8,6 +8,7 @@ import {
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -70,6 +70,7 @@ export function ImportEngineButton(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
const form = useForm<ImportEngineParams>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -82,7 +83,7 @@ export function ImportEngineButton(props: {
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (importParams: ImportEngineParams) => {
|
||||
await importEngine({ ...importParams, teamIdOrSlug: props.teamSlug });
|
||||
router.push(`/team/${props.teamSlug}/${props.projectSlug}/engine`);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,6 +91,7 @@ export function ImportEngineButton(props: {
|
||||
try {
|
||||
await importMutation.mutateAsync(data);
|
||||
toast.success("Engine imported successfully");
|
||||
setIsOpen(false);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : undefined;
|
||||
toast.error(
|
||||
@@ -102,42 +104,40 @@ export function ImportEngineButton(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="gap-2 rounded-full bg-card"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-3.5" />
|
||||
Import Engine
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="gap-2 rounded-full bg-card"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-3.5" />
|
||||
Import Engine
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogContent className="p-0 rounded-lg overflow-hidden">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Import Engine Instance</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import an Engine instance hosted on your infrastructure.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<Link
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-card p-3 text-sm hover:bg-accent"
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/get-started"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Get help setting up Engine for free
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="px-4 lg:px-6 pb-6">
|
||||
<Link
|
||||
className="mb-4 flex items-center justify-between gap-2 rounded-full border border-border bg-card p-3 text-sm hover:bg-accent"
|
||||
href="https://portal.thirdweb.com/engine/v2/get-started"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Get help setting up Engine for free
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<div className="space-y-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -146,6 +146,7 @@ export function ImportEngineButton(props: {
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-card"
|
||||
autoFocus
|
||||
placeholder="Enter a descriptive label"
|
||||
{...field}
|
||||
@@ -164,6 +165,7 @@ export function ImportEngineButton(props: {
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-card"
|
||||
placeholder="Enter your Engine URL"
|
||||
type="url"
|
||||
{...field}
|
||||
@@ -182,8 +184,8 @@ export function ImportEngineButton(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button className="min-w-28 gap-1.5 rounded-full" type="submit">
|
||||
<div className="border-t bg-card p-6 flex justify-end">
|
||||
<Button className="gap-2 rounded-full" type="submit">
|
||||
{importMutation.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
@@ -191,10 +193,10 @@ export function ImportEngineButton(props: {
|
||||
)}
|
||||
Import
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,15 +77,12 @@ export default async function ProjectLayout(props: {
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
</div>
|
||||
<div className="fixed right-4 bottom-4 z-50">
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
label="Ask AI Assistant"
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
team={team}
|
||||
/>
|
||||
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import type { Address } from "thirdweb";
|
||||
import { BuyWidget, type TokenInfo } from "thirdweb/react";
|
||||
import type { TokenInfo } from "thirdweb/react";
|
||||
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { bridgeAppThirdwebClient } from "../../constants";
|
||||
|
||||
export function UniversalBridgeEmbed({
|
||||
chainId,
|
||||
@@ -16,16 +13,16 @@ export function UniversalBridgeEmbed({
|
||||
token: TokenInfo | undefined;
|
||||
amount?: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<BuyWidget
|
||||
client={bridgeAppThirdwebClient}
|
||||
amount={amount || "0"}
|
||||
chain={chain}
|
||||
tokenAddress={token?.address as Address | undefined}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
/>
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<BuyAndSwapEmbed
|
||||
chain={chain}
|
||||
buyAmount={amount}
|
||||
tokenAddress={token?.address}
|
||||
pageType="bridge"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PublicPageConnectButton } from "../../(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default function BridgeLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"h-screen w-screen bg-background font-sans antialiased",
|
||||
"min-h-dvh bg-background font-sans antialiased flex flex-col",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { bridgeAppThirdwebClient } from "./constants";
|
||||
|
||||
const title = "thirdweb Payments: Swap, Bridge, and Onramp";
|
||||
@@ -49,8 +51,11 @@ export default async function BridgePage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex h-screen w-full flex-col items-center justify-center overflow-hidden border py-10">
|
||||
<main className="container z-10 flex justify-center">
|
||||
<div className="grow flex flex-col relative overflow-hidden">
|
||||
<PageHeader />
|
||||
|
||||
<div className="flex grow flex-col items-center justify-center py-36 px-4 min-h-dvh relative overflow-hidden">
|
||||
<DotsBackgroundPattern />
|
||||
<UniversalBridgeEmbed
|
||||
amount={amount as string}
|
||||
chainId={chainId ? Number(chainId) : undefined}
|
||||
@@ -64,42 +69,28 @@ export default async function BridgePage({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
|
||||
src="/assets/login/background.svg"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-8 z-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<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>
|
||||
<div className="bg-background relative z-10">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BoxIcon,
|
||||
LogOutIcon,
|
||||
MailIcon,
|
||||
PenIcon,
|
||||
RocketIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
@@ -73,33 +74,39 @@ export function AccountOnboardingLayout(props: {
|
||||
const teamOnboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
description: "Provide team details",
|
||||
icon: BoxIcon,
|
||||
icon: PenIcon,
|
||||
number: 1,
|
||||
title: "Team Details",
|
||||
},
|
||||
{
|
||||
description: "Choose a plan that fits your needs",
|
||||
description: "Activate your 14 day free trial",
|
||||
icon: RocketIcon,
|
||||
number: 2,
|
||||
title: "Plan Selection",
|
||||
title: "Payment Details",
|
||||
},
|
||||
{
|
||||
description: "Invite members to your team",
|
||||
description: "Invite your team mates",
|
||||
icon: UsersIcon,
|
||||
number: 3,
|
||||
title: "Team Members",
|
||||
},
|
||||
{
|
||||
description: "Create your first project",
|
||||
icon: BoxIcon,
|
||||
number: 4,
|
||||
title: "Create Project",
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamOnboardingLayout(props: {
|
||||
children: React.ReactNode;
|
||||
currentStep: 1 | 2 | 3;
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingLayout
|
||||
currentStep={props.currentStep}
|
||||
steps={teamOnboardingSteps}
|
||||
title="Setup your team"
|
||||
title="Get started with thirdweb"
|
||||
>
|
||||
{props.children}
|
||||
</OnboardingLayout>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { upload } from "thirdweb/storage";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { sendTeamInvites } from "@/actions/team/sendTeamInvite";
|
||||
import {
|
||||
reportOnboardingCompleted,
|
||||
reportOnboardingStarted,
|
||||
reportTeamMemberStepCompleted,
|
||||
} from "@/analytics/report";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
@@ -123,8 +123,8 @@ export function InviteTeamMembers(props: {
|
||||
}}
|
||||
onComplete={() => {
|
||||
// at this point the team onboarding is complete
|
||||
reportOnboardingCompleted();
|
||||
router.replace(`/team/${props.team.slug}`);
|
||||
reportTeamMemberStepCompleted();
|
||||
router.push(`/get-started/team/${props.team.slug}/create-project`);
|
||||
}}
|
||||
team={props.team}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function EmptyStateChatPageContent(props: {
|
||||
onLoginClick: undefined | (() => void);
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden py-10 lg:py-16">
|
||||
<div className="relative overflow-hidden py-10 lg:py-16">
|
||||
{props.showAurora && (
|
||||
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--nebula-pink-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--nebula-pink-foreground)/10%)]" />
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,38 @@ export function NebulaLoggedOutStatePage(props: {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Deprecation banner */}
|
||||
<div className="bg-destructive p-6">
|
||||
<div className="container text-md text-destructive-foreground">
|
||||
<h4 className="font-bold text-lg">
|
||||
Nebula is now{" "}
|
||||
<Link
|
||||
className="underline text-destructive-foreground hover:text-destructive-foreground/80"
|
||||
href="https://thirdweb.com/ai"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb AI.
|
||||
</Link>{" "}
|
||||
This site will be deprecated on September 30, 2025.
|
||||
</h4>
|
||||
<p>
|
||||
To continue using thirdweb AI, please sign in to the{" "}
|
||||
<Link
|
||||
className="underline text-destructive-foreground hover:text-destructive-foreground/80"
|
||||
href="https://thirdweb.com/login"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb dashboard
|
||||
</Link>{" "}
|
||||
and create a new project.
|
||||
</p>
|
||||
<p>
|
||||
If you have any funds left in your Nebula account, please withdraw
|
||||
them before September 30, 2025.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPage === "connect" && (
|
||||
<NebulaLoginPage redirectPath={`/?${redirectPathParams}`} />
|
||||
)}
|
||||
|
||||
@@ -148,12 +148,12 @@ const nextConfig = {
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/insight",
|
||||
source: "/insight/:path*",
|
||||
destination: "https://insight.thirdweb.com/reference",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/payments/backend",
|
||||
source: "/payments/backend/:path*",
|
||||
destination: "/reference#tag/payments",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
// Allow streaming responses up to 5 minutes
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Congratulations! You have accessed the protected route.",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { quotes, stringifyImports, stringifyProps } from "@/lib/code-gen";
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(() =>
|
||||
import("../../../../components/code/code.client").then((m) => ({
|
||||
default: m.CodeClient,
|
||||
})),
|
||||
);
|
||||
|
||||
function CodeLoading() {
|
||||
return (
|
||||
<div className="flex min-h-[300px] grow items-center justify-center bg-card border rounded-lg">
|
||||
<LoadingDots />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeGen(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
return (
|
||||
<div className="flex w-full grow flex-col">
|
||||
<Suspense fallback={<CodeLoading />}>
|
||||
<CodeClient className="grow" code={getCode(props.options)} lang="ts" />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCode(options: SwapWidgetPlaygroundOptions) {
|
||||
const imports = {
|
||||
thirdweb: ["createThirdwebClient"] as string[],
|
||||
"thirdweb/react": ["SwapWidget"] as string[],
|
||||
};
|
||||
|
||||
let themeProp: string | undefined;
|
||||
if (
|
||||
options.theme.type === "dark" &&
|
||||
Object.keys(options.theme.darkColorOverrides || {}).length > 0
|
||||
) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("darkTheme");
|
||||
}
|
||||
|
||||
if (options.theme.type === "light") {
|
||||
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
}
|
||||
|
||||
const props: Record<string, string | undefined | boolean> = {
|
||||
theme: themeProp,
|
||||
prefill:
|
||||
options.prefill?.buyToken || options.prefill?.sellToken
|
||||
? JSON.stringify(options.prefill, null, 2)
|
||||
: undefined,
|
||||
currency:
|
||||
options.currency !== "USD" && options.currency
|
||||
? quotes(options.currency)
|
||||
: undefined,
|
||||
showThirdwebBranding:
|
||||
options.showThirdwebBranding === false ? false : undefined,
|
||||
client: "client",
|
||||
};
|
||||
|
||||
return `\
|
||||
${stringifyImports(imports)}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "....",
|
||||
});
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<SwapWidget ${stringifyProps(props)} />
|
||||
);
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { CoinsIcon, PaletteIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useId } from "react";
|
||||
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { CollapsibleSection } from "@/app/wallets/sign-in/components/CollapsibleSection";
|
||||
import { ColorFormGroup } from "@/app/wallets/sign-in/components/ColorFormGroup";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import { TokenSelector } from "@/components/ui/TokenSelector";
|
||||
import { THIRDWEB_CLIENT } from "@/lib/client";
|
||||
import { CurrencySelector } from "../../../../components/blocks/CurrencySelector";
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
export function LeftSection(props: {
|
||||
options: SwapWidgetPlaygroundOptions;
|
||||
setOptions: React.Dispatch<React.SetStateAction<SwapWidgetPlaygroundOptions>>;
|
||||
}) {
|
||||
const { options, setOptions } = props;
|
||||
const setThemeType = (themeType: "dark" | "light") => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: {
|
||||
...v.theme,
|
||||
type: themeType,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const themeId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CollapsibleSection defaultOpen icon={CoinsIcon} title="Token Selection">
|
||||
<div className="flex flex-col gap-6 pt-5">
|
||||
<section className="flex flex-col gap-3">
|
||||
<Label htmlFor="currency">Display Currency</Label>
|
||||
<CurrencySelector
|
||||
value={options.currency}
|
||||
onChange={(currency) => {
|
||||
setOptions((v) => ({ ...v, currency }));
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-dashed" />
|
||||
|
||||
<TokenFieldset
|
||||
title="Sell Token"
|
||||
type="sellToken"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
|
||||
<div className="border-t border-dashed" />
|
||||
|
||||
<TokenFieldset
|
||||
title="Buy Token"
|
||||
type="buyToken"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection icon={PaletteIcon} title="Appearance">
|
||||
{/* Theme */}
|
||||
<section className="flex flex-col gap-3 pt-6">
|
||||
<Label htmlFor="theme"> Theme </Label>
|
||||
<CustomRadioGroup
|
||||
id={themeId}
|
||||
onValueChange={setThemeType}
|
||||
options={[
|
||||
{ label: "Dark", value: "dark" },
|
||||
{ label: "Light", value: "light" },
|
||||
]}
|
||||
value={options.theme.type}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Colors */}
|
||||
<ColorFormGroup
|
||||
onChange={(newTheme) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: newTheme,
|
||||
}));
|
||||
}}
|
||||
theme={options.theme}
|
||||
/>
|
||||
|
||||
<div className="my-4 flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={options.showThirdwebBranding}
|
||||
id={"branding"}
|
||||
onCheckedChange={(checked) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
showThirdwebBranding: checked === true,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={"branding"}>Show Branding</Label>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenFieldset(props: {
|
||||
type: "buyToken" | "sellToken";
|
||||
title: string;
|
||||
options: SwapWidgetPlaygroundOptions;
|
||||
setOptions: React.Dispatch<React.SetStateAction<SwapWidgetPlaygroundOptions>>;
|
||||
}) {
|
||||
const { options, setOptions } = props;
|
||||
|
||||
const chainId = options.prefill?.[props.type]?.chainId;
|
||||
const tokenAddress = options.prefill?.[props.type]?.tokenAddress;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 font-medium">{props.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Sets the default token and amount to{" "}
|
||||
{props.type === "buyToken" ? "buy" : "sell"} in the widget, <br />
|
||||
User can change this default selection in the widget
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Chain selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Chain</Label>
|
||||
<BridgeNetworkSelector
|
||||
chainId={chainId}
|
||||
onChange={(chainId) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
prefill: {
|
||||
...v.prefill,
|
||||
[props.type]: {
|
||||
...v.prefill?.[props.type],
|
||||
chainId,
|
||||
tokenAddress: undefined, // clear token selection
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}
|
||||
placeholder="Select a chain"
|
||||
className="bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token selection - only show if chain is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label>Token</Label>
|
||||
<TokenSelector
|
||||
chainId={chainId}
|
||||
client={THIRDWEB_CLIENT}
|
||||
disableAddress
|
||||
enabled={true}
|
||||
onChange={(token) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
prefill: {
|
||||
...v.prefill,
|
||||
[props.type]: {
|
||||
chainId: token.chainId,
|
||||
tokenAddress: token.address,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}
|
||||
placeholder="Select a token"
|
||||
selectedToken={
|
||||
tokenAddress && chainId
|
||||
? {
|
||||
address: tokenAddress,
|
||||
chainId: chainId,
|
||||
}
|
||||
: chainId
|
||||
? {
|
||||
address: getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: chainId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className="bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chainId && (
|
||||
<div className="space-y-2">
|
||||
<Label> Token Amount</Label>
|
||||
<Input
|
||||
className="bg-card"
|
||||
value={options.prefill?.[props.type]?.amount || ""}
|
||||
onChange={(e) => {
|
||||
setOptions((v) => {
|
||||
return {
|
||||
...v,
|
||||
prefill: {
|
||||
...v.prefill,
|
||||
[props.type]: {
|
||||
...v.prefill?.[props.type],
|
||||
amount: e.target.value,
|
||||
chainId: chainId,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { darkTheme, lightTheme, SwapWidget } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { THIRDWEB_CLIENT } from "@/lib/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeGen } from "./code";
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
type Tab = "ui" | "code";
|
||||
|
||||
export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
|
||||
return "ui";
|
||||
});
|
||||
|
||||
function setPreviewTab(tab: "ui" | "code") {
|
||||
_setPreviewTab(tab);
|
||||
}
|
||||
|
||||
const themeObj =
|
||||
props.options.theme.type === "dark"
|
||||
? darkTheme({
|
||||
colors: props.options.theme.darkColorOverrides,
|
||||
})
|
||||
: lightTheme({
|
||||
colors: props.options.theme.lightColorOverrides,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-4 xl:sticky xl:top-0 xl:max-h-[100vh] xl:w-[764px]">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
isActive: previewTab === "ui",
|
||||
name: "UI",
|
||||
onClick: () => setPreviewTab("ui"),
|
||||
},
|
||||
{
|
||||
isActive: previewTab === "code",
|
||||
name: "Code",
|
||||
onClick: () => setPreviewTab("code"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-[300px] grow justify-center rounded-lg",
|
||||
previewTab !== "code" && "items-center",
|
||||
)}
|
||||
>
|
||||
<BackgroundPattern />
|
||||
|
||||
{previewTab === "ui" && (
|
||||
<SwapWidget
|
||||
client={THIRDWEB_CLIENT}
|
||||
theme={themeObj}
|
||||
prefill={props.options.prefill}
|
||||
currency={props.options.currency}
|
||||
showThirdwebBranding={props.options.showThirdwebBranding}
|
||||
key={JSON.stringify({
|
||||
prefill: props.options.prefill,
|
||||
})}
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewTab === "code" && <CodeGen options={props.options} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundPattern() {
|
||||
const color = "hsl(var(--foreground)/15%)";
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-[-1]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(${color} 1px, transparent 1px)`,
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButtons(props: {
|
||||
tabs: Array<{
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-start gap-1 rounded-lg border bg-card p-2 shadow-md md:inline-flex">
|
||||
{props.tabs.map((tab) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"gap-2 px-4 text-base",
|
||||
tab.isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "bg-transparent text-muted-foreground",
|
||||
)}
|
||||
key={tab.name}
|
||||
onClick={tab.onClick}
|
||||
variant="ghost"
|
||||
>
|
||||
{tab.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LeftSection } from "./left-section";
|
||||
import { RightSection } from "./right-section";
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const defaultOptions: SwapWidgetPlaygroundOptions = {
|
||||
prefill: undefined,
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
theme: {
|
||||
darkColorOverrides: {},
|
||||
lightColorOverrides: {},
|
||||
type: "dark",
|
||||
},
|
||||
};
|
||||
|
||||
export function SwapWidgetPlayground() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [options, setOptions] =
|
||||
useState<SwapWidgetPlaygroundOptions>(defaultOptions);
|
||||
|
||||
// change theme on global theme change
|
||||
useEffect(() => {
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
theme: {
|
||||
...prev.theme,
|
||||
type: theme === "dark" ? "dark" : "light",
|
||||
},
|
||||
}));
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection options={options} setOptions={setOptions} />
|
||||
</div>
|
||||
<RightSection options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SwapWidgetProps, ThemeOverrides } from "thirdweb/react";
|
||||
|
||||
export type SwapWidgetPlaygroundOptions = {
|
||||
theme: {
|
||||
type: "dark" | "light";
|
||||
darkColorOverrides: ThemeOverrides["colors"];
|
||||
lightColorOverrides: ThemeOverrides["colors"];
|
||||
};
|
||||
currency?: SwapWidgetProps["currency"];
|
||||
prefill?: SwapWidgetProps["prefill"];
|
||||
showThirdwebBranding?: SwapWidgetProps["showThirdwebBranding"];
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import { PageLayout } from "@/components/blocks/APIHeader";
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { SwapWidgetPlayground } from "./components/swap-widget-playground";
|
||||
|
||||
const title = "Swap Widget Component";
|
||||
const description =
|
||||
"Embeddable component for users to swap any cryptocurrency with cross-chain support";
|
||||
const ogDescription =
|
||||
"Configure a component to swap cryptocurrency with specified amounts, customization, and more. This interactive playground shows how to customize the component.";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
description: ogDescription,
|
||||
title,
|
||||
image: {
|
||||
icon: "payments",
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
icon={BringToFrontIcon}
|
||||
title={title}
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/references/typescript/v5/SwapWidget?utm_source=playground"
|
||||
>
|
||||
<SwapWidgetPlayground />
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
BlocksIcon,
|
||||
BotIcon,
|
||||
BoxIcon,
|
||||
BringToFrontIcon,
|
||||
CircleDollarSignIcon,
|
||||
CircleUserIcon,
|
||||
CreditCardIcon,
|
||||
DollarSignIcon,
|
||||
@@ -185,6 +187,13 @@ export const paymentsFeatureCards: FeatureCardMetadata[] = [
|
||||
description:
|
||||
"Enable users to pay for onchain transactions with fiat or crypto",
|
||||
},
|
||||
{
|
||||
icon: CircleDollarSignIcon,
|
||||
title: "x402",
|
||||
link: "/payments/x402",
|
||||
description:
|
||||
"Use the x402 payment protocol to pay for API calls using any web3 wallet",
|
||||
},
|
||||
];
|
||||
|
||||
export const accountAbstractionsFeatureCards: FeatureCardMetadata[] = [
|
||||
@@ -247,3 +256,12 @@ export const aiFeatureCards: FeatureCardMetadata[] = [
|
||||
description: "Use the thirdweb blockchain models with the Vercel AI SDK",
|
||||
},
|
||||
];
|
||||
|
||||
export const bridgeFeatureCards: FeatureCardMetadata[] = [
|
||||
{
|
||||
icon: BringToFrontIcon,
|
||||
title: "Swap Widget",
|
||||
link: "/bridge/swap-widget",
|
||||
description: "A widget for swapping tokens with cross-chain support",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRightIcon, BotIcon, Code2Icon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
BotIcon,
|
||||
BringToFrontIcon,
|
||||
Code2Icon,
|
||||
} from "lucide-react";
|
||||
import type { ShadcnSidebarLink } from "@/components/blocks/full-width-sidebar-layout";
|
||||
import { ContractIcon } from "../icons/ContractIcon";
|
||||
import { PayIcon } from "../icons/PayIcon";
|
||||
@@ -201,6 +206,23 @@ const payments: ShadcnSidebarLink = {
|
||||
href: "/payments/transactions",
|
||||
label: "Onchain Transaction",
|
||||
},
|
||||
{
|
||||
href: "/payments/x402",
|
||||
label: "x402",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const bridge: ShadcnSidebarLink = {
|
||||
subMenu: {
|
||||
label: "Bridge",
|
||||
icon: BringToFrontIcon,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href: "/bridge/swap-widget",
|
||||
label: "Swap Widget",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -240,6 +262,7 @@ export const sidebarLinks: ShadcnSidebarLink[] = [
|
||||
transactions,
|
||||
contracts,
|
||||
payments,
|
||||
bridge,
|
||||
tokens,
|
||||
accountAbstractions,
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ThirdwebIcon } from "../icons/ThirdwebMiniLogo";
|
||||
import {
|
||||
accountAbstractionsFeatureCards,
|
||||
aiFeatureCards,
|
||||
bridgeFeatureCards,
|
||||
contractsFeatureCards,
|
||||
type FeatureCardMetadata,
|
||||
paymentsFeatureCards,
|
||||
@@ -47,6 +48,7 @@ export default function Page() {
|
||||
title="Contracts"
|
||||
/>
|
||||
<FeatureSection featureCards={paymentsFeatureCards} title="Payments" />
|
||||
<FeatureSection featureCards={bridgeFeatureCards} title="Bridge" />
|
||||
<FeatureSection
|
||||
featureCards={accountAbstractionsFeatureCards}
|
||||
title="Account Abstraction"
|
||||
|
||||
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
sellerAddress: "0x0000000000000000000000000000000000000000",
|
||||
title: "",
|
||||
transactionData: "",
|
||||
widget: "checkout",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
},
|
||||
@@ -37,12 +36,12 @@ export function CheckoutPlayground() {
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
lockedWidget="checkout"
|
||||
widget="checkout"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
</div>
|
||||
<RightSection lockedWidget="checkout" options={options} />
|
||||
<RightSection widget="checkout" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { LoadingDots } from "../../../components/ui/LoadingDots";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import {
|
||||
quotes,
|
||||
stringifyImports,
|
||||
stringifyProps,
|
||||
} from "../../../lib/code-gen";
|
||||
import type { BridgeComponentsPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(() =>
|
||||
@@ -16,58 +21,114 @@ function CodeLoading() {
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeGen(props: { options: BridgeComponentsPlaygroundOptions }) {
|
||||
export function CodeGen(props: {
|
||||
widget: "buy" | "checkout" | "transaction";
|
||||
options: BridgeComponentsPlaygroundOptions;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full grow flex-col">
|
||||
<Suspense fallback={<CodeLoading />}>
|
||||
<CodeClient className="grow" code={getCode(props.options)} lang="tsx" />
|
||||
<CodeClient
|
||||
className="grow"
|
||||
code={getCode(props.widget, props.options)}
|
||||
lang="tsx"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCode(options: BridgeComponentsPlaygroundOptions) {
|
||||
function getCode(
|
||||
widget: "buy" | "checkout" | "transaction",
|
||||
options: BridgeComponentsPlaygroundOptions,
|
||||
) {
|
||||
const componentName =
|
||||
widget === "buy"
|
||||
? "BuyWidget"
|
||||
: widget === "checkout"
|
||||
? "CheckoutWidget"
|
||||
: widget === "transaction"
|
||||
? "TransactionWidget"
|
||||
: "";
|
||||
|
||||
const imports = {
|
||||
chains: [] as string[],
|
||||
react: ["PayEmbed"] as string[],
|
||||
thirdweb: [] as string[],
|
||||
wallets: [] as string[],
|
||||
"thirdweb/chains": [] as string[],
|
||||
"thirdweb/react": [componentName] as string[],
|
||||
thirdweb: ["createThirdwebClient", "defineChain"] as string[],
|
||||
"thirdweb/wallets": ["createWallet"] as string[],
|
||||
};
|
||||
|
||||
// Check if we have a custom chain (not base chain which has id 8453)
|
||||
const isCustomChain =
|
||||
options.payOptions.buyTokenChain &&
|
||||
options.payOptions.buyTokenChain.id !== 8453;
|
||||
|
||||
if (isCustomChain) {
|
||||
// Add defineChain to imports if using a custom chain
|
||||
imports.thirdweb.push("defineChain");
|
||||
} else {
|
||||
// Otherwise use the base chain
|
||||
imports.chains.push("base");
|
||||
let themeProp: string | undefined;
|
||||
if (
|
||||
options.theme.type === "dark" &&
|
||||
Object.keys(options.theme.darkColorOverrides || {}).length > 0
|
||||
) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("darkTheme");
|
||||
}
|
||||
|
||||
imports.wallets.push("createWallet");
|
||||
|
||||
const componentName = (() => {
|
||||
switch (options.payOptions.widget) {
|
||||
case "buy":
|
||||
return "BuyWidget";
|
||||
case "checkout":
|
||||
return "CheckoutWidget";
|
||||
case "transaction":
|
||||
return "TransactionWidget";
|
||||
default:
|
||||
return "PayEmbed";
|
||||
if (options.theme.type === "light") {
|
||||
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
})();
|
||||
imports.react.push(componentName);
|
||||
imports.chains.push("defineChain");
|
||||
}
|
||||
|
||||
const transaction =
|
||||
widget === "transaction"
|
||||
? `claimTo({
|
||||
contract: nftContract,
|
||||
quantity: 1n,
|
||||
to: account?.address || "",
|
||||
tokenId: 2n,
|
||||
})`
|
||||
: undefined;
|
||||
|
||||
const props: Record<string, string | undefined | boolean> = {
|
||||
theme: themeProp,
|
||||
client: "client",
|
||||
description: options.payOptions.description
|
||||
? quotes(options.payOptions.description)
|
||||
: undefined,
|
||||
image: options.payOptions.image
|
||||
? quotes(options.payOptions.image)
|
||||
: undefined,
|
||||
name: options.payOptions.title
|
||||
? quotes(options.payOptions.title)
|
||||
: undefined,
|
||||
paymentMethods:
|
||||
options.payOptions.paymentMethods.length === 2
|
||||
? undefined
|
||||
: JSON.stringify(options.payOptions.paymentMethods),
|
||||
currency: options.payOptions.currency
|
||||
? quotes(options.payOptions.currency)
|
||||
: undefined,
|
||||
chain: `defineChain(${options.payOptions.buyTokenChain.id})`,
|
||||
showThirdwebBranding:
|
||||
options.payOptions.showThirdwebBranding === false ? false : undefined,
|
||||
amount: options.payOptions.buyTokenAmount
|
||||
? quotes(options.payOptions.buyTokenAmount)
|
||||
: undefined,
|
||||
tokenAddress: options.payOptions.buyTokenAddress
|
||||
? quotes(options.payOptions.buyTokenAddress)
|
||||
: undefined,
|
||||
seller: options.payOptions.sellerAddress
|
||||
? quotes(options.payOptions.sellerAddress)
|
||||
: undefined,
|
||||
buttonLabel: options.payOptions.buttonLabel
|
||||
? quotes(options.payOptions.buttonLabel)
|
||||
: undefined,
|
||||
transaction: transaction,
|
||||
};
|
||||
|
||||
return `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
|
||||
${imports.thirdweb.length > 0 ? `import { ${imports.thirdweb.join(", ")} } from "thirdweb";` : ""}
|
||||
${stringifyImports(imports)}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "....",
|
||||
@@ -75,20 +136,7 @@ const client = createThirdwebClient({
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<${componentName}
|
||||
client={client}
|
||||
chain={defineChain(${options.payOptions.buyTokenChain.id})}
|
||||
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t tokenAddress="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.buttonLabel ? `\n\t buttonLabel="${options.payOptions.buttonLabel}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
|
||||
options.payOptions.widget === "transaction"
|
||||
? `\n\t transaction={claimTo({
|
||||
contract: nftContract,
|
||||
quantity: 1n,
|
||||
tokenId: 2n,
|
||||
to: account?.address || "",
|
||||
})}`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<${componentName} ${stringifyProps(props)} />
|
||||
);
|
||||
}`;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExternalLinkIcon,
|
||||
FuelIcon,
|
||||
PaletteIcon,
|
||||
Settings2Icon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CreditCardIcon, PaletteIcon, Settings2Icon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useId, useState } from "react";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TokenSelector } from "@/components/ui/TokenSelector";
|
||||
import { THIRDWEB_CLIENT } from "@/lib/client";
|
||||
import type { TokenMetadata } from "@/lib/types";
|
||||
import { CurrencySelector } from "../../../components/blocks/CurrencySelector";
|
||||
import { CollapsibleSection } from "../../wallets/sign-in/components/CollapsibleSection";
|
||||
import { ColorFormGroup } from "../../wallets/sign-in/components/ColorFormGroup";
|
||||
import type { BridgeComponentsPlaygroundOptions } from "./types";
|
||||
@@ -36,11 +23,10 @@ export function LeftSection(props: {
|
||||
setOptions: React.Dispatch<
|
||||
React.SetStateAction<BridgeComponentsPlaygroundOptions>
|
||||
>;
|
||||
lockedWidget?: "buy" | "checkout" | "transaction";
|
||||
widget: "buy" | "checkout" | "transaction";
|
||||
}) {
|
||||
const { options, setOptions } = props;
|
||||
const { theme, payOptions } = options;
|
||||
const effectiveWidget = props.lockedWidget || payOptions.widget || "buy";
|
||||
const setThemeType = (themeType: "dark" | "light") => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
@@ -68,7 +54,7 @@ export function LeftSection(props: {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const payModeId = useId();
|
||||
const _payModeId = useId();
|
||||
const buyTokenAmountId = useId();
|
||||
const sellerAddressId = useId();
|
||||
const paymentAmountId = useId();
|
||||
@@ -119,87 +105,30 @@ export function LeftSection(props: {
|
||||
title="Payment Options"
|
||||
>
|
||||
<div className="flex flex-col gap-6 pt-5">
|
||||
{props.lockedWidget === undefined && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<Label htmlFor={payModeId}>Widget</Label>
|
||||
<CustomRadioGroup
|
||||
id={payModeId}
|
||||
onValueChange={(value) => {
|
||||
setOptions(
|
||||
(v) =>
|
||||
({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
widget: value as "buy" | "checkout" | "transaction",
|
||||
},
|
||||
}) satisfies BridgeComponentsPlaygroundOptions,
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ label: "Buy", value: "buy" },
|
||||
{ label: "Checkout", value: "checkout" },
|
||||
{ label: "Transaction", value: "transaction" },
|
||||
]}
|
||||
value={payOptions.widget || "buy"}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<Label htmlFor="currency">Display Currency</Label>
|
||||
<Select
|
||||
value={payOptions.currency || "USD"}
|
||||
onValueChange={(value) => {
|
||||
<CurrencySelector
|
||||
value={payOptions.currency}
|
||||
onChange={(currency) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
currency: value,
|
||||
currency: currency,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="USD">USD - US Dollar</SelectItem>
|
||||
<SelectItem value="EUR">EUR - Euro</SelectItem>
|
||||
<SelectItem value="GBP">GBP - British Pound</SelectItem>
|
||||
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
|
||||
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
|
||||
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
|
||||
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
|
||||
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
|
||||
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
|
||||
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
|
||||
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
|
||||
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
|
||||
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
|
||||
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
|
||||
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
|
||||
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
|
||||
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
|
||||
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
|
||||
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
|
||||
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
|
||||
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
|
||||
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
|
||||
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
|
||||
{(effectiveWidget === "buy" || effectiveWidget === "checkout") && (
|
||||
{(props.widget === "buy" || props.widget === "checkout") && (
|
||||
<div className="space-y-4">
|
||||
{/* Chain selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Chain</Label>
|
||||
<SingleNetworkSelector
|
||||
<BridgeNetworkSelector
|
||||
chainId={selectedChain}
|
||||
disableTestnets={true}
|
||||
onChange={handleChainChange}
|
||||
placeholder="Select a chain"
|
||||
className="bg-card"
|
||||
@@ -211,7 +140,6 @@ export function LeftSection(props: {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Token</Label>
|
||||
<TokenSelector
|
||||
addNativeTokenIfMissing={true}
|
||||
chainId={selectedChain}
|
||||
client={THIRDWEB_CLIENT}
|
||||
enabled={true}
|
||||
@@ -228,7 +156,7 @@ export function LeftSection(props: {
|
||||
{/* Mode-specific form fields */}
|
||||
<div className="my-2">
|
||||
{/* Buy Mode - Amount and Payment Methods */}
|
||||
{effectiveWidget === "buy" && (
|
||||
{props.widget === "buy" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={buyTokenAmountId}>Amount</Label>
|
||||
@@ -309,7 +237,7 @@ export function LeftSection(props: {
|
||||
)}
|
||||
|
||||
{/* Checkout Mode - Seller Address, Price and Payment Methods */}
|
||||
{effectiveWidget === "checkout" && (
|
||||
{props.widget === "checkout" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={sellerAddressId}>Seller Address</Label>
|
||||
@@ -411,7 +339,7 @@ export function LeftSection(props: {
|
||||
)}
|
||||
|
||||
{/* Transaction Mode Options */}
|
||||
{effectiveWidget === "transaction" && (
|
||||
{props.widget === "transaction" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Transaction</Label>
|
||||
@@ -559,26 +487,6 @@ export function LeftSection(props: {
|
||||
<Label htmlFor={"branding"}>Show Branding</Label>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection icon={FuelIcon} title="Sponsor gas fees">
|
||||
<div className="mt-4 flex items-start gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="">
|
||||
Abstract away gas fees for users of your app by enabling ERC-4337
|
||||
Account Abstraction
|
||||
</p>
|
||||
|
||||
<Link
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
href="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Account Abstraction
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type Tab = "ui" | "code";
|
||||
export function RightSection(props: {
|
||||
options: BridgeComponentsPlaygroundOptions;
|
||||
tab?: string;
|
||||
lockedWidget?: "buy" | "checkout" | "transaction";
|
||||
widget: "buy" | "checkout" | "transaction";
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
|
||||
@@ -52,10 +52,8 @@ export function RightSection(props: {
|
||||
colors: props.options.theme.lightColorOverrides,
|
||||
});
|
||||
|
||||
const effectiveWidget = props.lockedWidget || props.options.payOptions.widget;
|
||||
|
||||
let embed: React.ReactNode;
|
||||
if (effectiveWidget === "buy") {
|
||||
if (props.widget === "buy") {
|
||||
embed = (
|
||||
<BuyWidget
|
||||
amount={props.options.payOptions.buyTokenAmount}
|
||||
@@ -76,7 +74,7 @@ export function RightSection(props: {
|
||||
);
|
||||
}
|
||||
|
||||
if (effectiveWidget === "checkout") {
|
||||
if (props.widget === "checkout") {
|
||||
embed = (
|
||||
<CheckoutWidget
|
||||
amount={props.options.payOptions.buyTokenAmount}
|
||||
@@ -104,7 +102,7 @@ export function RightSection(props: {
|
||||
);
|
||||
}
|
||||
|
||||
if (effectiveWidget === "transaction") {
|
||||
if (props.widget === "transaction") {
|
||||
embed = (
|
||||
<TransactionWidget
|
||||
client={THIRDWEB_CLIENT}
|
||||
@@ -155,7 +153,9 @@ export function RightSection(props: {
|
||||
|
||||
{previewTab === "ui" && embed}
|
||||
|
||||
{previewTab === "code" && <CodeGen options={props.options} />}
|
||||
{previewTab === "code" && (
|
||||
<CodeGen widget={props.widget} options={props.options} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ const CURRENCIES = [
|
||||
"ISK",
|
||||
] as const;
|
||||
|
||||
type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
|
||||
type SupportedFiatCurrency = (typeof CURRENCIES)[number];
|
||||
|
||||
export type BridgeComponentsPlaygroundOptions = {
|
||||
theme: {
|
||||
@@ -37,7 +37,6 @@ export type BridgeComponentsPlaygroundOptions = {
|
||||
lightColorOverrides: ThemeOverrides["colors"];
|
||||
};
|
||||
payOptions: {
|
||||
widget?: "buy" | "checkout" | "transaction";
|
||||
title: string | undefined;
|
||||
image: string | undefined;
|
||||
description: string | undefined;
|
||||
|
||||
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
sellerAddress: "0x0000000000000000000000000000000000000000",
|
||||
title: "",
|
||||
transactionData: "",
|
||||
widget: "buy",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
},
|
||||
@@ -36,13 +35,9 @@ export function BuyPlayground() {
|
||||
return (
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
lockedWidget="buy"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
<LeftSection widget="buy" options={options} setOptions={setOptions} />
|
||||
</div>
|
||||
<RightSection lockedWidget="buy" options={options} />
|
||||
<RightSection widget="buy" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { OverviewPage } from "@/components/blocks/OverviewPage";
|
||||
import { PayIcon } from "../../icons/PayIcon";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { paymentsFeatureCards } from "../data/pages-metadata";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<OverviewPage
|
||||
icon={PayIcon}
|
||||
title="Payments"
|
||||
description={
|
||||
<>
|
||||
Allow developers and users to receive and spend any token on any EVM
|
||||
chain
|
||||
</>
|
||||
}
|
||||
description="thirdweb Payments allow developers to create advanced payment flows to monetize their apps through product sales, peer to peer payments, token sales, and more"
|
||||
featureCards={paymentsFeatureCards}
|
||||
icon={PayIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
sellerAddress: "0x0000000000000000000000000000000000000000",
|
||||
title: "",
|
||||
transactionData: "",
|
||||
widget: "transaction",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
},
|
||||
@@ -37,12 +36,12 @@ export function TransactionPlayground() {
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
lockedWidget="transaction"
|
||||
widget="transaction"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
</div>
|
||||
<RightSection lockedWidget="transaction" options={options} />
|
||||
<RightSection widget="transaction" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CodeClient } from "@workspace/ui/components/code/code.client";
|
||||
import { CodeIcon, LockIcon } from "lucide-react";
|
||||
import { arbitrumSepolia } from "thirdweb/chains";
|
||||
import {
|
||||
ConnectButton,
|
||||
getDefaultToken,
|
||||
useActiveAccount,
|
||||
useActiveWallet,
|
||||
} from "thirdweb/react";
|
||||
import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { THIRDWEB_CLIENT } from "../../../../lib/client";
|
||||
|
||||
const chain = arbitrumSepolia;
|
||||
const token = getDefaultToken(chain, "USDC");
|
||||
|
||||
export function X402ClientPreview() {
|
||||
const activeWallet = useActiveWallet();
|
||||
const activeAccount = useActiveAccount();
|
||||
const paidApiCall = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!activeWallet) {
|
||||
throw new Error("No active wallet");
|
||||
}
|
||||
const fetchWithPay = wrapFetchWithPayment(
|
||||
fetch,
|
||||
THIRDWEB_CLIENT,
|
||||
activeWallet,
|
||||
);
|
||||
const response = await fetchWithPay("/api/paywall");
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const handlePayClick = async () => {
|
||||
paidApiCall.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
|
||||
<ConnectButton
|
||||
client={THIRDWEB_CLIENT}
|
||||
chain={chain}
|
||||
detailsButton={{
|
||||
displayBalanceToken: {
|
||||
[chain.id]: token!.address,
|
||||
},
|
||||
}}
|
||||
supportedTokens={{
|
||||
[chain.id]: [token!],
|
||||
}}
|
||||
/>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<LockIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">Paid API Call</span>
|
||||
<span className="text-xl font-bold text-red-600">$0.01</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePayClick}
|
||||
className="w-full mb-4"
|
||||
size="lg"
|
||||
disabled={paidApiCall.isPending || !activeAccount}
|
||||
>
|
||||
Pay Now
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={"https://faucet.circle.com/"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Click here to get USDC on {chain.name}
|
||||
</a>
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<CodeIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">API Call Response</span>
|
||||
</div>
|
||||
{paidApiCall.isPending && <div className="text-center">Loading...</div>}
|
||||
{paidApiCall.isError && (
|
||||
<div className="text-center">Error: {paidApiCall.error.message}</div>
|
||||
)}
|
||||
{paidApiCall.data && (
|
||||
<CodeClient
|
||||
code={JSON.stringify(paidApiCall.data, null, 2)}
|
||||
lang="json"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { CodeServer } from "@workspace/ui/components/code/code.server";
|
||||
import { CircleDollarSignIcon, Code2Icon } from "lucide-react";
|
||||
import { CodeExample, TabName } from "@/components/code/code-example";
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { PageLayout } from "../../../components/blocks/APIHeader";
|
||||
import { createMetadata } from "../../../lib/metadata";
|
||||
import { X402ClientPreview } from "./components/x402-client-preview";
|
||||
|
||||
const title = "x402 Payments";
|
||||
const description =
|
||||
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
|
||||
const ogDescription =
|
||||
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title,
|
||||
description: ogDescription,
|
||||
image: {
|
||||
icon: "payments",
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
icon={CircleDollarSignIcon}
|
||||
title={title}
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/payments/x402?utm_source=playground"
|
||||
>
|
||||
<X402Example />
|
||||
<div className="h-8" />
|
||||
<ServerCodeExample />
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerCodeExample() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Next.js Server Code Example
|
||||
</h2>
|
||||
<p className="max-w-4xl text-muted-foreground text-balance text-sm md:text-base">
|
||||
Create middleware with the thirdweb facilitator to settle transactions
|
||||
with your server wallet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex grow flex-col border-b md:border-r md:border-b-0">
|
||||
<TabName icon={Code2Icon} name="Server Code" />
|
||||
<CodeServer
|
||||
className="h-full rounded-none border-none"
|
||||
code={`// src/middleware.ts
|
||||
|
||||
import { facilitator, settlePayment } from "thirdweb/x402";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({ secretKey: "your-secret-key" });
|
||||
const thirdwebX402Facilitator = facilitator({
|
||||
client,
|
||||
serverWalletAddress: "0xYourWalletAddress",
|
||||
});
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const method = request.method.toUpperCase();
|
||||
const resourceUrl = request.nextUrl.toString();
|
||||
const paymentData = request.headers.get("X-PAYMENT");
|
||||
|
||||
const result = await settlePayment({
|
||||
resourceUrl,
|
||||
method,
|
||||
paymentData,
|
||||
payTo: "0xYourWalletAddress",
|
||||
network: "eip155:11155111", // or any other chain id
|
||||
price: "$0.01", // can also be a ERC20 token amount
|
||||
facilitator: thirdwebX402Facilitator,
|
||||
});
|
||||
|
||||
if (result.status === 200) {
|
||||
// payment successful, execute the request
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// otherwise, request payment
|
||||
return NextResponse.json(result.responseBody, {
|
||||
status: result.status,
|
||||
headers: result.responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: ["/api/paid-endpoint"],
|
||||
};
|
||||
|
||||
`}
|
||||
lang="tsx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function X402Example() {
|
||||
return (
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Client Code Example",
|
||||
description:
|
||||
"Wrap your fetch requests with the `wrapFetchWithPayment` function to enable x402 payments.",
|
||||
}}
|
||||
code={`import { createThirdwebClient } from "thirdweb";
|
||||
import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
import { useActiveWallet } from "thirdweb/react";
|
||||
|
||||
const client = createThirdwebClient({ clientId: "your-client-id" });
|
||||
|
||||
export default function Page() {
|
||||
const wallet = useActiveWallet();
|
||||
|
||||
const onClick = async () => {
|
||||
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
|
||||
const response = await fetchWithPay('/api/paid-endpoint');
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={onClick}>Pay Now</Button>
|
||||
);
|
||||
}`}
|
||||
lang="tsx"
|
||||
preview={<X402ClientPreview />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -59,13 +59,7 @@ export function ConnectButtonPage(props: {
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [connectOptions, setConnectOptions] =
|
||||
useState<ConnectPlaygroundOptions>(() => ({
|
||||
...defaultConnectOptions,
|
||||
theme: {
|
||||
...defaultConnectOptions.theme,
|
||||
type: theme === "dark" ? "dark" : "light",
|
||||
},
|
||||
}));
|
||||
useState<ConnectPlaygroundOptions>(defaultConnectOptions);
|
||||
|
||||
// change theme on global theme change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import {
|
||||
quotes,
|
||||
stringifyIgnoreFalsy,
|
||||
stringifyImports,
|
||||
stringifyProps,
|
||||
} from "@/lib/code-gen";
|
||||
import { LoadingDots } from "../../../../components/ui/LoadingDots";
|
||||
import type { ConnectPlaygroundOptions } from "./types";
|
||||
|
||||
@@ -34,10 +40,10 @@ export function CodeGen(props: { connectOptions: ConnectPlaygroundOptions }) {
|
||||
function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
const walletCodes: string[] = [];
|
||||
const imports = {
|
||||
chains: [] as string[],
|
||||
react: [] as string[],
|
||||
thirdweb: [] as string[],
|
||||
wallets: [] as string[],
|
||||
"thirdweb/chains": [] as string[],
|
||||
"thirdweb/react": ["ConnectButton"] as string[],
|
||||
thirdweb: ["createThirdwebClient"] as string[],
|
||||
"thirdweb/wallets": [] as string[],
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -49,7 +55,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
options: ${JSON.stringify(connectOptions.inAppWallet.methods)},
|
||||
},
|
||||
})`);
|
||||
imports.wallets.push("inAppWallet");
|
||||
imports["thirdweb/wallets"].push("inAppWallet");
|
||||
}
|
||||
|
||||
for (const wallet of connectOptions.walletIds) {
|
||||
@@ -57,7 +63,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
}
|
||||
|
||||
if (connectOptions.walletIds.length > 0) {
|
||||
imports.wallets.push("createWallet");
|
||||
imports["thirdweb/wallets"].push("createWallet");
|
||||
}
|
||||
|
||||
let themeProp: string | undefined;
|
||||
@@ -68,7 +74,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(connectOptions.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("darkTheme");
|
||||
imports["thirdweb/react"].push("darkTheme");
|
||||
}
|
||||
|
||||
if (connectOptions.theme.type === "light") {
|
||||
@@ -78,14 +84,14 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(connectOptions.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("lightTheme");
|
||||
imports["thirdweb/react"].push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
}
|
||||
|
||||
if (connectOptions.enableAccountAbstraction) {
|
||||
imports.chains.push("ethereum");
|
||||
imports["thirdweb/chains"].push("ethereum");
|
||||
}
|
||||
|
||||
const props: Record<string, string | undefined> = {
|
||||
@@ -118,11 +124,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
};
|
||||
|
||||
return `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { ConnectButton } from "thirdweb/react";
|
||||
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
|
||||
${imports.wallets.length > 0 ? `import { ${imports.wallets.join(", ")} } from "thirdweb/wallets";` : ""}
|
||||
${imports.chains.length > 0 ? `import { ${imports.chains.join(", ")} } from "thirdweb/chains";` : ""}
|
||||
${stringifyImports(imports)}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "....",
|
||||
@@ -160,35 +162,3 @@ const accountAbstractCode = `\
|
||||
sponsorGas: true
|
||||
}
|
||||
`;
|
||||
|
||||
function stringifyIgnoreFalsy(
|
||||
value: Record<string, string | undefined | boolean>,
|
||||
) {
|
||||
const _value: Record<string, string | boolean> = {};
|
||||
|
||||
for (const key in value) {
|
||||
if (value[key] !== undefined && value[key] !== "") {
|
||||
_value[key] = value[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(_value);
|
||||
}
|
||||
|
||||
function stringifyProps(props: Record<string, string | undefined | boolean>) {
|
||||
const _props: Record<string, string | undefined | boolean> = {};
|
||||
|
||||
for (const key in props) {
|
||||
if (props[key] !== undefined && props[key] !== "") {
|
||||
_props[key] = props[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(_props)
|
||||
.map(([key, value]) => `${key}={${value}}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function quotes(value: string) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import type { SwapWidgetProps } from "thirdweb/react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function CurrencySelector(props: {
|
||||
value: SwapWidgetProps["currency"];
|
||||
onChange: (value: SwapWidgetProps["currency"]) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={props.value}
|
||||
onValueChange={(value) => {
|
||||
props.onChange(value as SwapWidgetProps["currency"]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="USD">USD - US Dollar</SelectItem>
|
||||
<SelectItem value="EUR">EUR - Euro</SelectItem>
|
||||
<SelectItem value="GBP">GBP - British Pound</SelectItem>
|
||||
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
|
||||
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
|
||||
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
|
||||
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
|
||||
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
|
||||
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
|
||||
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
|
||||
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
|
||||
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
|
||||
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
|
||||
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
|
||||
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
|
||||
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
|
||||
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
|
||||
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
|
||||
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
|
||||
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
|
||||
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
|
||||
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
|
||||
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ChainIcon } from "@/components/blocks/ChainIcon";
|
||||
import { MultiSelect } from "@/components/blocks/multi-select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SelectWithSearch } from "@/components/ui/select-with-search";
|
||||
import { useAllChainsData } from "@/hooks/chains";
|
||||
import { useBridgeSupportedChains } from "@/hooks/chains";
|
||||
|
||||
function cleanChainName(chainName: string) {
|
||||
return chainName.replace("Mainnet", "");
|
||||
@@ -13,164 +11,31 @@ function cleanChainName(chainName: string) {
|
||||
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
export function MultiNetworkSelector(props: {
|
||||
selectedChainIds: number[];
|
||||
onChange: (chainIds: number[]) => void;
|
||||
disableChainId?: boolean;
|
||||
className?: string;
|
||||
priorityChains?: number[];
|
||||
popoverContentClassName?: string;
|
||||
chainIds?: number[];
|
||||
selectedBadgeClassName?: string;
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData().data;
|
||||
|
||||
const chainsToShow = useMemo(() => {
|
||||
if (!props.chainIds) {
|
||||
return allChains;
|
||||
}
|
||||
const chainIdSet = new Set(props.chainIds);
|
||||
return allChains.filter((chain) => chainIdSet.has(chain.chainId));
|
||||
}, [allChains, props.chainIds]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
let sortedChains = chainsToShow;
|
||||
|
||||
if (props.priorityChains) {
|
||||
const priorityChainsSet = new Set();
|
||||
for (const chainId of props.priorityChains || []) {
|
||||
priorityChainsSet.add(chainId);
|
||||
}
|
||||
|
||||
const priorityChains = (props.priorityChains || [])
|
||||
.map((chainId) => {
|
||||
return idToChain.get(chainId);
|
||||
})
|
||||
.filter((v) => !!v);
|
||||
|
||||
const otherChains = chainsToShow.filter(
|
||||
(chain) => !priorityChainsSet.has(chain.chainId),
|
||||
);
|
||||
|
||||
sortedChains = [...priorityChains, ...otherChains];
|
||||
}
|
||||
|
||||
return sortedChains.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [chainsToShow, props.priorityChains, idToChain]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const chain = idToChain.get(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());
|
||||
},
|
||||
[idToChain],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const chain = idToChain.get(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">
|
||||
<ChainIcon
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
/>
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
|
||||
{!props.disableChainId && (
|
||||
<Badge className="gap-2" variant="outline">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[idToChain, props.disableChainId],
|
||||
);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
className={props.className}
|
||||
disabled={allChains.length === 0}
|
||||
onSelectedValuesChange={(chainIds) => {
|
||||
props.onChange(chainIds.map(Number));
|
||||
}}
|
||||
options={options}
|
||||
overrideSearchFn={searchFn}
|
||||
placeholder={
|
||||
allChains.length === 0 ? "Loading Chains..." : "Select Chains"
|
||||
}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
renderOption={renderOption}
|
||||
searchPlaceholder="Search by Name or Chain Id"
|
||||
selectedBadgeClassName={props.selectedBadgeClassName}
|
||||
selectedValues={props.selectedChainIds.map(String)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleNetworkSelector(props: {
|
||||
export function BridgeNetworkSelector(props: {
|
||||
chainId: number | undefined;
|
||||
onChange: (chainId: number) => void;
|
||||
className?: string;
|
||||
popoverContentClassName?: string;
|
||||
// if specified - only these chains will be shown
|
||||
chainIds?: number[];
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
disableChainId?: boolean;
|
||||
align?: "center" | "start" | "end";
|
||||
disableTestnets?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData().data;
|
||||
|
||||
const chainsToShow = useMemo(() => {
|
||||
let chains = allChains;
|
||||
|
||||
if (props.disableTestnets) {
|
||||
chains = chains.filter((chain) => !chain.testnet);
|
||||
}
|
||||
|
||||
if (props.chainIds) {
|
||||
const chainIdSet = new Set(props.chainIds);
|
||||
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
|
||||
}
|
||||
|
||||
return chains;
|
||||
}, [allChains, props.chainIds, props.disableTestnets]);
|
||||
const chainsQuery = useBridgeSupportedChains();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return chainsToShow.map((chain) => {
|
||||
return (chainsQuery.data || [])?.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [chainsToShow]);
|
||||
}, [chainsQuery.data]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const chain = idToChain.get(Number(option.value));
|
||||
const chain = chainsQuery.data?.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return false;
|
||||
}
|
||||
@@ -180,12 +45,14 @@ export function SingleNetworkSelector(props: {
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
},
|
||||
[idToChain],
|
||||
[chainsQuery.data],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const chain = idToChain.get(Number(option.value));
|
||||
const chain = chainsQuery.data?.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return option.label;
|
||||
}
|
||||
@@ -193,27 +60,16 @@ export function SingleNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ChainIcon className="size-5" ipfsSrc={chain.icon} loading="lazy" />
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
|
||||
{!props.disableChainId && (
|
||||
<Badge className="gap-2 max-sm:hidden" variant="outline">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[idToChain, props.disableChainId],
|
||||
[chainsQuery.data],
|
||||
);
|
||||
|
||||
const isLoadingChains = allChains.length === 0;
|
||||
const isLoadingChains = chainsQuery.isPending;
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
|
||||
@@ -19,7 +19,7 @@ export function OverviewPage(props: {
|
||||
<h1 className="mb-2 font-bold text-5xl tracking-tight">
|
||||
{props.title}
|
||||
</h1>
|
||||
<p className="text-base text-muted-foreground leading-normal text-pretty">
|
||||
<p className="text-base text-muted-foreground leading-normal text-pretty max-w-[80%]">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: TODO */
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useShowMore } from "../../lib/useShowMore";
|
||||
import { Input } from "../ui/input";
|
||||
import { ScrollShadow } from "../ui/ScrollShadow";
|
||||
|
||||
interface MultiSelectProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
selectedValues: string[];
|
||||
onSelectedValuesChange: (value: string[]) => void;
|
||||
placeholder: string;
|
||||
searchPlaceholder?: string;
|
||||
popoverContentClassName?: string;
|
||||
selectedBadgeClassName?: string;
|
||||
/**
|
||||
* Maximum number of items to display. Extra selected items will be summarized.
|
||||
* Optional, defaults to 3.
|
||||
*/
|
||||
maxCount?: number;
|
||||
|
||||
className?: string;
|
||||
|
||||
overrideSearchFn?: (
|
||||
option: { value: string; label: string },
|
||||
searchTerm: string,
|
||||
) => boolean;
|
||||
|
||||
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
(
|
||||
{
|
||||
options,
|
||||
onSelectedValuesChange,
|
||||
placeholder,
|
||||
maxCount = Number.POSITIVE_INFINITY,
|
||||
className,
|
||||
selectedValues,
|
||||
overrideSearchFn,
|
||||
renderOption,
|
||||
popoverContentClassName,
|
||||
selectedBadgeClassName,
|
||||
searchPlaceholder,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
setIsPopoverOpen(true);
|
||||
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
||||
const newSelectedValues = [...selectedValues];
|
||||
newSelectedValues.pop();
|
||||
onSelectedValuesChange(newSelectedValues);
|
||||
}
|
||||
},
|
||||
[selectedValues, onSelectedValuesChange],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(option: string) => {
|
||||
const newSelectedValues = selectedValues.includes(option)
|
||||
? selectedValues.filter((value) => value !== option)
|
||||
: [...selectedValues, option];
|
||||
onSelectedValuesChange(newSelectedValues);
|
||||
},
|
||||
[selectedValues, onSelectedValuesChange],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onSelectedValuesChange([]);
|
||||
}, [onSelectedValuesChange]);
|
||||
|
||||
const handleTogglePopover = () => {
|
||||
setIsPopoverOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const clearExtraOptions = useCallback(() => {
|
||||
const newSelectedValues = selectedValues.slice(0, maxCount);
|
||||
onSelectedValuesChange(newSelectedValues);
|
||||
}, [selectedValues, onSelectedValuesChange, maxCount]);
|
||||
|
||||
// show 50 initially and then 20 more when reaching the end
|
||||
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
|
||||
|
||||
const optionsToShow = useMemo(() => {
|
||||
const filteredOptions: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[] = [];
|
||||
|
||||
const searchValLowercase = searchValue.toLowerCase();
|
||||
|
||||
for (let i = 0; i <= options.length - 1; i++) {
|
||||
if (filteredOptions.length >= itemsToShow) {
|
||||
break;
|
||||
}
|
||||
const option = options[i];
|
||||
if (!option) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (overrideSearchFn) {
|
||||
if (overrideSearchFn(option, searchValLowercase)) {
|
||||
filteredOptions.push(option);
|
||||
}
|
||||
} else {
|
||||
if (option.label.toLowerCase().includes(searchValLowercase)) {
|
||||
filteredOptions.push(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredOptions;
|
||||
}, [options, searchValue, itemsToShow, overrideSearchFn]);
|
||||
|
||||
// scroll to top when options change
|
||||
const popoverElRef = useRef<HTMLDivElement>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: ok
|
||||
useEffect(() => {
|
||||
const scrollContainer =
|
||||
popoverElRef.current?.querySelector("[data-scrollable]");
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: 0,
|
||||
});
|
||||
}
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
|
||||
className,
|
||||
)}
|
||||
onClick={handleTogglePopover}
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
<div className="flex w-full justify-between">
|
||||
{/* badges */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{selectedValues.slice(0, maxCount).map((value) => {
|
||||
const option = options.find((o) => o.value === value);
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClosableBadge
|
||||
className={selectedBadgeClassName}
|
||||
key={value}
|
||||
label={option.label}
|
||||
onClose={() => toggleOption(value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* +X more */}
|
||||
{selectedValues.length > maxCount && (
|
||||
<ClosableBadge
|
||||
label={`+ ${selectedValues.length - maxCount} more`}
|
||||
onClose={clearExtraOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Clear All */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: TODO */}
|
||||
{/* biome-ignore lint/a11y/useFocusableInteractive: TODO */}
|
||||
<div
|
||||
className="rounded p-1 hover:bg-accent"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<XIcon className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className="flex h-full min-h-6"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<ChevronDownIcon className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{placeholder}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
className={cn("p-0", popoverContentClassName)}
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
ref={popoverElRef}
|
||||
sideOffset={10}
|
||||
style={{
|
||||
maxHeight: "var(--radix-popover-content-available-height)",
|
||||
width: "var(--radix-popover-trigger-width)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={searchPlaceholder || "Search"}
|
||||
value={searchValue}
|
||||
/>
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<ScrollShadow
|
||||
className="rounded"
|
||||
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
|
||||
>
|
||||
{/* List */}
|
||||
<div>
|
||||
{optionsToShow.length === 0 && (
|
||||
<div className="flex justify-center py-10">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{optionsToShow.map((option, i) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
aria-selected={isSelected}
|
||||
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
|
||||
key={option.value}
|
||||
onClick={() => toggleOption(option.value)}
|
||||
ref={
|
||||
i === optionsToShow.length - 1 ? lastItemRef : undefined
|
||||
}
|
||||
role="option"
|
||||
variant="ghost"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-foreground",
|
||||
isSelected
|
||||
? "bg-inverted text-inverted-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 grow">
|
||||
{renderOption ? renderOption(option) : option.label}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function ClosableBadge(props: {
|
||||
label: string;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-border bg-muted px-2 py-1 text-foreground text-xs",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.label}
|
||||
<XIcon
|
||||
className="h-3 w-3 cursor-pointer text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClose();
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
MultiSelect.displayName = "MultiSelect";
|
||||
@@ -2,22 +2,15 @@
|
||||
|
||||
import { CoinsIcon } from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
getAddress,
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Img } from "@/components/ui/Img";
|
||||
import { SelectWithSearch } from "@/components/ui/select-with-search";
|
||||
import { useAllChainsData } from "@/hooks/chains";
|
||||
import { useTokensData } from "@/hooks/useTokensData";
|
||||
import type { TokenMetadata } from "@/lib/types";
|
||||
import { cn, fallbackChainIcon, replaceIpfsUrl } from "@/lib/utils";
|
||||
|
||||
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
export function TokenSelector(props: {
|
||||
selectedToken: { chainId: number; address: string } | undefined;
|
||||
onChange: (token: TokenMetadata) => void;
|
||||
@@ -28,49 +21,13 @@ export function TokenSelector(props: {
|
||||
client: ThirdwebClient;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
addNativeTokenIfMissing: boolean;
|
||||
}) {
|
||||
const tokensQuery = useTokensData({
|
||||
chainId: props.chainId,
|
||||
enabled: props.enabled,
|
||||
});
|
||||
|
||||
const { idToChain } = useAllChainsData().data;
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
if (!tokensQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.addNativeTokenIfMissing) {
|
||||
const hasNativeToken = tokensQuery.data.some(
|
||||
(token) => token.address === checksummedNativeTokenAddress,
|
||||
);
|
||||
|
||||
if (!hasNativeToken && props.chainId) {
|
||||
return [
|
||||
{
|
||||
address: checksummedNativeTokenAddress,
|
||||
chainId: props.chainId,
|
||||
decimals: 18,
|
||||
name:
|
||||
idToChain.get(props.chainId)?.nativeCurrency.name ??
|
||||
"Native Token",
|
||||
symbol:
|
||||
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
|
||||
} satisfies TokenMetadata,
|
||||
...tokensQuery.data,
|
||||
];
|
||||
}
|
||||
}
|
||||
return tokensQuery.data;
|
||||
}, [
|
||||
tokensQuery.data,
|
||||
props.chainId,
|
||||
props.addNativeTokenIfMissing,
|
||||
idToChain,
|
||||
]);
|
||||
|
||||
const tokens = tokensQuery.data || [];
|
||||
const addressChainToToken = useMemo(() => {
|
||||
const value = new Map<string, TokenMetadata>();
|
||||
for (const token of tokens) {
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { THIRDWEB_CLIENT } from "../lib/client";
|
||||
import { isProd } from "../lib/env";
|
||||
|
||||
async function fetchChainsFromApi() {
|
||||
const res = await fetch("https://api.thirdweb.com/v1/chains");
|
||||
type BridgeChain = {
|
||||
chainId: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
nativeCurrency: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
};
|
||||
|
||||
async function fetchBridgeSupportedChainsFromApi() {
|
||||
const res = await fetch(
|
||||
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/chains`,
|
||||
{
|
||||
headers: {
|
||||
"x-client-id": THIRDWEB_CLIENT.clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data as ChainMetadata[];
|
||||
return json.data as BridgeChain[];
|
||||
}
|
||||
|
||||
export function useAllChainsData() {
|
||||
const query = useQuery({
|
||||
export function useBridgeSupportedChains() {
|
||||
return useQuery({
|
||||
queryFn: async () => {
|
||||
const idToChain = new Map<number, ChainMetadata>();
|
||||
const chains = await fetchChainsFromApi();
|
||||
|
||||
for (const c of chains) {
|
||||
idToChain.set(c.chainId, c);
|
||||
}
|
||||
|
||||
return {
|
||||
allChains: chains,
|
||||
idToChain,
|
||||
};
|
||||
return fetchBridgeSupportedChainsFromApi();
|
||||
},
|
||||
queryKey: ["all-chains"],
|
||||
queryKey: ["bridge-supported-chains"],
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data || { allChains: [], idToChain: new Map() },
|
||||
isPending: query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export function stringifyProps(
|
||||
props: Record<string, string | undefined | boolean>,
|
||||
) {
|
||||
const _props: Record<string, string | undefined | boolean> = {};
|
||||
|
||||
for (const key in props) {
|
||||
if (props[key] !== undefined && props[key] !== "") {
|
||||
_props[key] = props[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(_props)
|
||||
.map(([key, value]) => `${key}={${value}}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function quotes(value: string) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
export function stringifyImports(imports: Record<string, string[]>) {
|
||||
let code = "";
|
||||
for (const key in imports) {
|
||||
if (imports[key].length > 0) {
|
||||
code += `import { ${imports[key].join(", ")} } from "${key}";\n`;
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function stringifyIgnoreFalsy(
|
||||
value: Record<string, string | undefined | boolean>,
|
||||
) {
|
||||
const _value: Record<string, string | boolean> = {};
|
||||
|
||||
for (const key in value) {
|
||||
if (value[key] !== undefined && value[key] !== "") {
|
||||
_value[key] = value[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(_value);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user