Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d617bf77b3 | ||
|
|
4be655dd08 | ||
|
|
74d4074f42 | ||
|
|
a36c850b86 | ||
|
|
59a8dad48e | ||
|
|
f7aa3e6239 | ||
|
|
827819b911 | ||
|
|
0d7e5e563d | ||
|
|
8665e8fe14 | ||
|
|
438027a40e | ||
|
|
66c7df98e4 | ||
|
|
409ae17c15 | ||
|
|
be28b0c26e | ||
|
|
c661a485d0 | ||
|
|
90f36bc414 | ||
|
|
a77e98a48a | ||
|
|
fde39eb883 | ||
|
|
c672a84fc3 | ||
|
|
d52855dbda | ||
|
|
a3909e1794 | ||
|
|
f92201d353 | ||
|
|
45ba811950 | ||
|
|
7c458eaa49 | ||
|
|
f7ce6becd6 | ||
|
|
52d1ba32c4 | ||
|
|
1a7315c815 | ||
|
|
50c6c193c6 | ||
|
|
e114b2c4f9 | ||
|
|
d812cc370b | ||
|
|
7883127d51 | ||
|
|
53d0d74d9b | ||
|
|
c0948fb9fe | ||
|
|
9ae2991ad7 | ||
|
|
bab4ff5e14 | ||
|
|
621c1de62e | ||
|
|
8bb2b0a227 | ||
|
|
5f82df1652 | ||
|
|
fd4414b451 | ||
|
|
d10436d3d2 | ||
|
|
56b12c6094 | ||
|
|
abbdb0f113 | ||
|
|
d05dbcb5ac | ||
|
|
3a52f9d486 | ||
|
|
9a3ebe1cb1 | ||
|
|
ad2d22558b | ||
|
|
0aac9a8593 | ||
|
|
fa7a81260d | ||
|
|
e5326c43a9 | ||
|
|
57862d0bd0 | ||
|
|
834bbc0883 | ||
|
|
3a9aa1a72b | ||
|
|
572368edc3 | ||
|
|
9b5ca42091 | ||
|
|
c6849a8734 | ||
|
|
7617cc84fc | ||
|
|
e1ad7daf54 | ||
|
|
6ad015effb | ||
|
|
b86d520f3c | ||
|
|
2df891f380 | ||
|
|
58787a28f3 | ||
|
|
e4ba47bd1e | ||
|
|
710ac32b2f | ||
|
|
6258f7429c | ||
|
|
943cd1bbcf | ||
|
|
ed65f4cbe7 | ||
|
|
0483776825 | ||
|
|
cd142f9ff5 |
+20
-54
@@ -1,29 +1,22 @@
|
||||
#
|
||||
# Public (client)
|
||||
#
|
||||
|
||||
# API authorized domain
|
||||
# not required to build, defaults to prod
|
||||
NEXT_PUBLIC_THIRDWEB_DOMAIN="localhost:3000"
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
# API host. For local development, please use "https://api.thirdweb-dev.com"
|
||||
# otherwise: "https://api.thirdweb.com"
|
||||
# local host - http://127.0.0.1:3005
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST="https://api.thirdweb-dev.com"
|
||||
|
||||
# Bridge API. For local development, please use "https://bridge.thirdweb-dev.com"
|
||||
# otherwise: "https://bridge.thirdweb.com"
|
||||
NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST="https://bridge.thirdweb-dev.com"
|
||||
|
||||
# Paper API host
|
||||
# in-app wallet host
|
||||
NEXT_PUBLIC_THIRDWEB_EWS_API_HOST="https://ews.thirdweb-dev.com"
|
||||
|
||||
# thirdweb local api host
|
||||
# NEXT_PUBLIC_THIRDWEB_API_HOST="http://127.0.0.1:3005"
|
||||
|
||||
# Client ID
|
||||
# Get the client id from https://thirdweb.com/create-api-key and set it here,
|
||||
# Client ID - Required
|
||||
# Get the client id from https://thirdweb.com/create-api-key and set it here, and also set DASHBOARD_SECRET_KEY below
|
||||
# make sure to allow localhost:3000 in the allowed origins
|
||||
NEXT_PUBLIC_DASHBOARD_CLIENT_ID="<replace_me_with_your_client_id>"
|
||||
NEXT_PUBLIC_DASHBOARD_CLIENT_ID=""
|
||||
|
||||
# IPFS gateway url
|
||||
NEXT_PUBLIC_IPFS_GATEWAY_URL="https://{clientId}.thirdwebstorage-dev.com/ipfs/{cid}/{path}"
|
||||
@@ -39,62 +32,35 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
|
||||
# - not required for prod/staging
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
|
||||
# Stripe Customer portal
|
||||
NEXT_PUBLIC_STRIPE_KEY=
|
||||
# required for server wallet management
|
||||
NEXT_PUBLIC_THIRDWEB_VAULT_URL=""
|
||||
NEXT_PUBLIC_ENGINE_CLOUD_URL=""
|
||||
|
||||
|
||||
# Demo Engine - required for showing demo engine page
|
||||
NEXT_PUBLIC_DEMO_ENGINE_URL=""
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID=
|
||||
|
||||
#
|
||||
# Private (server)
|
||||
#
|
||||
# Get the secret key from https://thirdweb.com/create-api-key and set it here, Make sure to allow localhost:3000 in the allowed origins
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
# Get the secret key from https://thirdweb.com/create-api-key and set it here and also set NEXT_PUBLIC_DASHBOARD_CLIENT_ID above,
|
||||
# Make sure to allow localhost:3000 in the allowed origins
|
||||
DASHBOARD_SECRET_KEY="<replace_me_with_your_secret_key>"
|
||||
|
||||
# Client id for api routes
|
||||
# Client id for api routes - required for contract OG image generation
|
||||
API_ROUTES_CLIENT_ID=
|
||||
|
||||
# <moralis.io API key (used for api routes for token balances and wallet NFTs)
|
||||
# - not required to build (unless using wallet NFTs and token balances)>
|
||||
MORALIS_API_KEY=
|
||||
|
||||
# alchemy.com API key (used for wallet NFTS)
|
||||
# - cannot be restricted to IP/domain because vercel has no stable IPs and it happens during build & runtime api route call
|
||||
# - not required to build (unless testing wallet NFTs)>
|
||||
SSR_ALCHEMY_KEY=
|
||||
|
||||
|
||||
# Hubspot Access Token (used for contact us form)
|
||||
# - not required to build (unless testing contact us form)>
|
||||
HUBSPOT_ACCESS_TOKEN=
|
||||
|
||||
# Upload server url
|
||||
NEXT_PUBLIC_DASHBOARD_UPLOAD_SERVER="https://storage.thirdweb-preview.com"
|
||||
|
||||
# Unthread variables - only required for submitting the support form in /support
|
||||
UNTHREAD_API_KEY=""
|
||||
UNTHREAD_TRIAGE_CHANNEL_ID=""
|
||||
UNTHREAD_EMAIL_INBOX_ID=""
|
||||
UNTHREAD_FREE_TIER_ID=""
|
||||
UNTHREAD_GROWTH_TIER_ID=""
|
||||
UNTHREAD_PRO_TIER_ID=""
|
||||
|
||||
# Demo Engine
|
||||
NEXT_PUBLIC_DEMO_ENGINE_URL=""
|
||||
|
||||
# API server secret (required for thirdweb.com SIWE login). Copy from Vercel.
|
||||
API_SERVER_SECRET=""
|
||||
|
||||
# Used for the Faucet page (/<chain_id>)
|
||||
# Used in faucet on chain page (/<chain-id>) and login page (/login) - turnstile is disabled in localhost development
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
|
||||
REDIS_URL=""
|
||||
|
||||
ANALYTICS_SERVICE_URL=""
|
||||
|
||||
|
||||
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
|
||||
STRIPE_SECRET_KEY=""
|
||||
|
||||
# required for server wallet management
|
||||
NEXT_PUBLIC_THIRDWEB_VAULT_URL=""
|
||||
NEXT_PUBLIC_ENGINE_CLOUD_URL=""
|
||||
@@ -96,6 +96,12 @@ module.exports = {
|
||||
name: "posthog-js",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ["**/../@/**"],
|
||||
message: "Use absolute imports instead. Example: '@/foo/bar..'",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
|
||||
@@ -9,10 +9,10 @@ const ContentSecurityPolicy = `
|
||||
img-src * data: blob:;
|
||||
media-src * data: blob:;
|
||||
object-src 'none';
|
||||
style-src 'self' 'unsafe-inline' vercel.live;
|
||||
style-src 'self' 'unsafe-inline' vercel.live us.posthog.com;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com googletagmanager.com us-assets.i.posthog.com edit.framer.com;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com googletagmanager.com us-assets.i.posthog.com edit.framer.com framer.com googletagmanager.com;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
@@ -57,7 +56,6 @@
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-qrcode-logo": "^3.0.0",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.3",
|
||||
"remark-gfm": "4.0.1",
|
||||
@@ -130,4 +128,4 @@
|
||||
"update-checkly": "npx checkly deploy"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getRawAccount } from "../../app/(app)/account/settings/getAccount";
|
||||
import { getRawAccount } from "@/api/account/get-account";
|
||||
|
||||
export async function getRawAccountAction() {
|
||||
return getRawAccount();
|
||||
@@ -1,89 +0,0 @@
|
||||
"use server";
|
||||
import { isAddress, toTokens, ZERO_ADDRESS } from "thirdweb";
|
||||
import { getWalletBalance } from "thirdweb/wallets";
|
||||
import { MORALIS_API_KEY } from "@/constants/server-envs";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
|
||||
type BalanceQueryResponse = Array<{
|
||||
balance: string;
|
||||
decimals: number;
|
||||
name?: string;
|
||||
symbol: string;
|
||||
token_address: string;
|
||||
display_balance: string;
|
||||
}>;
|
||||
|
||||
export async function getTokenBalancesFromMoralis(params: {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
}): Promise<
|
||||
| { data: BalanceQueryResponse; error: undefined }
|
||||
| {
|
||||
data: undefined;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
const { contractAddress, chainId } = params;
|
||||
|
||||
if (!isAddress(contractAddress)) {
|
||||
return {
|
||||
data: undefined,
|
||||
error: "invalid address",
|
||||
};
|
||||
}
|
||||
|
||||
const getNativeBalance = async (): Promise<BalanceQueryResponse> => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(chainId, undefined);
|
||||
const balance = await getWalletBalance({
|
||||
address: contractAddress,
|
||||
chain,
|
||||
client: serverThirdwebClient,
|
||||
});
|
||||
return [
|
||||
{
|
||||
balance: balance.value.toString(),
|
||||
decimals: balance.decimals,
|
||||
display_balance: toTokens(balance.value, balance.decimals),
|
||||
name: "Native Token",
|
||||
symbol: balance.symbol,
|
||||
token_address: ZERO_ADDRESS,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getTokenBalances = async (): Promise<BalanceQueryResponse> => {
|
||||
const _chain = encodeURIComponent(`0x${chainId?.toString(16)}`);
|
||||
const _address = encodeURIComponent(contractAddress);
|
||||
const tokenBalanceEndpoint = `https://deep-index.moralis.io/api/v2/${_address}/erc20?chain=${_chain}`;
|
||||
|
||||
const resp = await fetch(tokenBalanceEndpoint, {
|
||||
headers: {
|
||||
"x-api-key": MORALIS_API_KEY,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
resp.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
const json = await resp.json();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
return json.map((balance: any) => ({
|
||||
...balance,
|
||||
display_balance: toTokens(BigInt(balance.balance), balance.decimals),
|
||||
}));
|
||||
};
|
||||
|
||||
const [nativeBalance, tokenBalances] = await Promise.all([
|
||||
getNativeBalance(),
|
||||
getTokenBalances(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: [...nativeBalance, ...tokenBalances],
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { MORALIS_API_KEY } from "@/constants/server-envs";
|
||||
import {
|
||||
generateAlchemyUrl,
|
||||
transformAlchemyResponseToNFT,
|
||||
} from "@/lib/wallet/nfts/alchemy";
|
||||
import {
|
||||
generateMoralisUrl,
|
||||
transformMoralisResponseToNFT,
|
||||
} from "@/lib/wallet/nfts/moralis";
|
||||
import type { WalletNFT } from "@/lib/wallet/nfts/types";
|
||||
import { getVercelEnv } from "@/utils/vercel";
|
||||
import { isAlchemySupported } from "../lib/wallet/nfts/isAlchemySupported";
|
||||
import { isMoralisSupported } from "../lib/wallet/nfts/isMoralisSupported";
|
||||
|
||||
type WalletNFTApiReturn =
|
||||
| { result: WalletNFT[]; error?: undefined }
|
||||
| { result?: undefined; error: string };
|
||||
|
||||
export async function getWalletNFTs(params: {
|
||||
chainId: number;
|
||||
owner: string;
|
||||
isInsightSupported: boolean;
|
||||
}): Promise<WalletNFTApiReturn> {
|
||||
const { chainId, owner } = params;
|
||||
|
||||
if (params.isInsightSupported) {
|
||||
const response = await getWalletNFTsFromInsight({ chainId, owner });
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: response.error,
|
||||
};
|
||||
}
|
||||
|
||||
return { result: response.data };
|
||||
}
|
||||
|
||||
if (isAlchemySupported(chainId)) {
|
||||
const url = generateAlchemyUrl({ chainId, owner });
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: {
|
||||
revalidate: 10, // cache for 10 seconds
|
||||
},
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
return { error: response.statusText };
|
||||
}
|
||||
try {
|
||||
const parsedResponse = await response.json();
|
||||
const result = await transformAlchemyResponseToNFT(parsedResponse, owner);
|
||||
|
||||
return { error: undefined, result };
|
||||
} catch (err) {
|
||||
console.error("Error fetching NFTs", err);
|
||||
return { error: "error parsing response" };
|
||||
}
|
||||
}
|
||||
|
||||
if (isMoralisSupported(chainId) && MORALIS_API_KEY) {
|
||||
const url = generateMoralisUrl({ chainId, owner });
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-API-Key": MORALIS_API_KEY,
|
||||
},
|
||||
method: "GET",
|
||||
next: {
|
||||
revalidate: 10, // cache for 10 seconds
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
return { error: response.statusText };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedResponse = await response.json();
|
||||
const result = await transformMoralisResponseToNFT(
|
||||
await parsedResponse,
|
||||
owner,
|
||||
chainId,
|
||||
);
|
||||
|
||||
return { result };
|
||||
} catch (err) {
|
||||
console.error("Error fetching NFTs", err);
|
||||
return { error: "error parsing response" };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "unsupported chain" };
|
||||
}
|
||||
|
||||
type OwnedNFTInsightResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
image_url?: string;
|
||||
background_color: string;
|
||||
external_url: string;
|
||||
metadata_url: string;
|
||||
extra_metadata: {
|
||||
customImage?: string;
|
||||
customAnimationUrl?: string;
|
||||
animation_original_url?: string;
|
||||
image_original_url?: string;
|
||||
};
|
||||
collection: {
|
||||
name: string;
|
||||
description: string;
|
||||
extra_metadata: Record<string, string>;
|
||||
};
|
||||
contract: {
|
||||
chain_id: number;
|
||||
address: string;
|
||||
type: "erc1155" | "erc721";
|
||||
name: string;
|
||||
};
|
||||
owner_addresses: string[];
|
||||
token_id: string;
|
||||
balance: string;
|
||||
token_type: "erc1155" | "erc721";
|
||||
};
|
||||
|
||||
async function getWalletNFTsFromInsight(params: {
|
||||
chainId: number;
|
||||
owner: string;
|
||||
}): Promise<
|
||||
| {
|
||||
data: WalletNFT[];
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
const { chainId, owner } = params;
|
||||
|
||||
const thirdwebDomain =
|
||||
getVercelEnv() === "production" ? "thirdweb" : "thirdweb-dev";
|
||||
const url = new URL(`https://insight.${thirdwebDomain}.com/v1/nfts`);
|
||||
url.searchParams.append("chain", chainId.toString());
|
||||
url.searchParams.append("limit", "10");
|
||||
url.searchParams.append("owner_address", owner);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.text();
|
||||
return {
|
||||
error: errorMessage,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nftsResponse = (await response.json()) as {
|
||||
data: OwnedNFTInsightResponse[];
|
||||
};
|
||||
|
||||
const isDev = getVercelEnv() !== "production";
|
||||
|
||||
// NOTE: ipfscdn.io/ to thirdwebstorage-dev.com/ replacement is temporary
|
||||
// This should be fixed in the insight dev endpoint
|
||||
|
||||
const walletNFTs = nftsResponse.data.map((nft) => {
|
||||
const walletNFT: WalletNFT = {
|
||||
chainId: nft.contract.chain_id,
|
||||
contractAddress: nft.contract.address,
|
||||
id: nft.token_id,
|
||||
metadata: {
|
||||
animation_url: isDev
|
||||
? nft.extra_metadata.animation_original_url?.replace(
|
||||
"ipfscdn.io/",
|
||||
"thirdwebstorage-dev.com/",
|
||||
)
|
||||
: nft.extra_metadata.animation_original_url,
|
||||
background_color: nft.background_color,
|
||||
description: nft.description,
|
||||
external_url: nft.external_url,
|
||||
image: isDev
|
||||
? nft.image_url?.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.image_url,
|
||||
name: nft.name,
|
||||
uri: isDev
|
||||
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.metadata_url,
|
||||
},
|
||||
owner: params.owner,
|
||||
supply: nft.balance,
|
||||
tokenAddress: nft.contract.address,
|
||||
tokenURI: nft.metadata_url,
|
||||
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
|
||||
};
|
||||
|
||||
return walletNFT;
|
||||
});
|
||||
|
||||
return {
|
||||
data: walletNFTs,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
let existingStripe: Stripe | undefined;
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import "server-only";
|
||||
import { randomBytes } from "crypto";
|
||||
import { format } from "date-fns";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function createTeam(options?: { name?: string; slug?: string }) {
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import type { ProductSKU } from "../types/billing";
|
||||
|
||||
// ----------------------------
|
||||
@@ -269,22 +270,6 @@ export function reportAssetBuyFailed(properties: {
|
||||
|
||||
// Assets Landing Page ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of asset creation started from the assets page
|
||||
* - To track which asset types are being created the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationStarted(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset creation started", {
|
||||
assetType: properties.assetType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of assets imported successfully from the assets page
|
||||
@@ -428,3 +413,107 @@ export function reportUpsellClicked(properties: UpsellParams) {
|
||||
export function reportPaymentCardClick(properties: { id: string }) {
|
||||
posthog.capture("payment card clicked", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To create a funnel "create payment link pageview" -> "payment link created" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkCreated(properties: {
|
||||
linkId: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
posthog.capture("payment link created", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track funnel "payment link pageview" -> "payment link buy successful" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkBuySuccessful() {
|
||||
posthog.capture("payment link buy successful");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of failed payment link buys
|
||||
* - To track what errors users encounter when trying to buy from a payment link
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkBuyFailed(properties: {
|
||||
errorMessage: string;
|
||||
}) {
|
||||
posthog.capture("payment link buy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To create a funnel for "asset pageview" -> "asset purchase successful" to understand the conversion rate
|
||||
* - To understand which asset types are being viewed the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetPageview(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
}) {
|
||||
posthog.capture("asset pageview", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the usage of fund wallet modal
|
||||
* - To create a funnel "fund wallet modal opened" -> "fund wallet buy successful" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportFundWalletOpened() {
|
||||
posthog.capture("fund wallet opened");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of successful fund wallet buys
|
||||
* - To create a funnel "fund wallet modal opened" -> "fund wallet buy successful" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportFundWalletSuccessful() {
|
||||
posthog.capture("fund wallet successful");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of failed fund wallet buys
|
||||
* - To track the errors that users encounter when trying to buy from a fund wallet modal
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportFundWalletFailed(params: { errorMessage: string }) {
|
||||
posthog.capture("fund wallet failed", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the conversion rate of the users choosing to create a token from new flow instead of the old flow
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenUpsellClicked(params: {
|
||||
assetType: "nft" | "coin";
|
||||
pageType: "explore" | "deploy-contract";
|
||||
}) {
|
||||
posthog.capture("token upsell clicked", params);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { isAccountOnboardingComplete } from "@/utils/account-onboarding";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { isAccountOnboardingComplete } from "../../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
/**
|
||||
* Just get the account object without enforcing onboarding.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type LinkedWallet = {
|
||||
createdAt: string;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookies";
|
||||
import {
|
||||
COOKIE_ACTIVE_ACCOUNT,
|
||||
COOKIE_PREFIX_TOKEN,
|
||||
LAST_USED_TEAM_ID,
|
||||
} from "@/constants/cookie";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
|
||||
export async function getAuthToken() {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { fetchDeployMetadata as sdkFetchDeployMetadata } from "thirdweb/contract";
|
||||
import { removeUndefinedFromObjectDeep } from "@/utils/object";
|
||||
import type { ContractId } from "./types";
|
||||
import type { ContractId } from "../../components/contract-components/types";
|
||||
|
||||
// metadata PRE publish, only has the compiler output info (from CLI)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
|
||||
export async function verifyContract(contract: ThirdwebContract) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://contract.thirdweb.com/verify/contract",
|
||||
{
|
||||
body: JSON.stringify({
|
||||
chainId: contract.chain.id,
|
||||
contractAddress: contract.address,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error verifying contract: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error verifying contract: ${error}`);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getProjectContracts,
|
||||
type ProjectContract,
|
||||
} from "@/api/getProjectContracts";
|
||||
} from "@/api/project/getProjectContracts";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
|
||||
export async function getSortedDeployedContracts(params: {
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type Project = ProjectResponse;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type AuditLogEntry = {
|
||||
who: {
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export async function createDedicatedSupportChannel(
|
||||
teamIdOrSlug: string,
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type AuthOption =
|
||||
| "email"
|
||||
@@ -12,6 +12,7 @@ export type AuthOption =
|
||||
| "google"
|
||||
| "facebook"
|
||||
| "x"
|
||||
| "tiktok"
|
||||
| "discord"
|
||||
| "farcaster"
|
||||
| "telegram"
|
||||
@@ -1,11 +1,11 @@
|
||||
import "server-only";
|
||||
import type { TeamResponse } from "@thirdweb-dev/service-utils";
|
||||
import { cookies } from "next/headers";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookies";
|
||||
import { getValidAccount } from "@/api/account/get-account";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookie";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { getValidAccount } from "../../app/(app)/account/settings/getAccount";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
import { getMemberByAccountId } from "./team-members";
|
||||
|
||||
export type Team = TeamResponse & { stripeCustomerId: string | null };
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type TeamInvite = {
|
||||
id: string;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
const TeamAccountRole = {
|
||||
MEMBER: "MEMBER",
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type VerifiedDomainResponse =
|
||||
| {
|
||||
@@ -200,7 +200,11 @@ export async function createPaymentLink(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
const response = (await res.json()) as {
|
||||
data: PaymentLink;
|
||||
};
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deletePaymentLink(props: {
|
||||
|
||||
@@ -18,6 +18,7 @@ export function DateRangeSelector(props: {
|
||||
}) {
|
||||
const { range, setRange } = props;
|
||||
const daysDiff = differenceInCalendarDays(range.to, range.from);
|
||||
|
||||
const matchingRange =
|
||||
normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime()
|
||||
? durationPresets.find((preset) => preset.days === daysDiff)
|
||||
@@ -85,7 +86,7 @@ export function getLastNDaysRange(id: DurationId) {
|
||||
throw new Error(`Invalid duration id: ${id}`);
|
||||
}
|
||||
|
||||
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
|
||||
const todayDate = new Date(Date.now());
|
||||
|
||||
const value: Range = {
|
||||
from: subDays(todayDate, durationInfo.days),
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
DurationId,
|
||||
Range,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
getLastNDaysRange,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
export function RangeSelector({
|
||||
range,
|
||||
interval,
|
||||
}: {
|
||||
range?: Range;
|
||||
interval: "day" | "week";
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
const [localRange, setRange] = useState<Range>(
|
||||
range || getLastNDaysRange("last-120"),
|
||||
);
|
||||
const [localInterval, setInterval] = useState<"day" | "week">(interval);
|
||||
|
||||
useQuery({
|
||||
queryFn: async () => {
|
||||
if (range) {
|
||||
setRange(range);
|
||||
return range;
|
||||
}
|
||||
if (searchParams) {
|
||||
const fromStringified = searchParams.get("from");
|
||||
const from = fromStringified
|
||||
? new Date(fromStringified)
|
||||
: getLastNDaysRange("last-120").from;
|
||||
const toStringified = searchParams.get("to");
|
||||
const to = toStringified ? new Date(toStringified) : new Date();
|
||||
const type = (searchParams.get("type") as DurationId) || "last-120";
|
||||
setRange({ from, to, type });
|
||||
setInterval(interval);
|
||||
return { from, to, type } satisfies Range;
|
||||
}
|
||||
return getLastNDaysRange("last-120");
|
||||
},
|
||||
queryKey: ["analytics-range", searchParams?.toString(), range],
|
||||
});
|
||||
|
||||
// prefetch for each interval and default range
|
||||
useQuery({
|
||||
queryFn: async () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams || {});
|
||||
for (const interval of ["day", "week"] as const) {
|
||||
newSearchParams.set("interval", interval);
|
||||
for (const type of [
|
||||
"last-120",
|
||||
"last-60",
|
||||
"last-30",
|
||||
"last-7",
|
||||
] as const) {
|
||||
const newRange = getLastNDaysRange(type);
|
||||
newSearchParams.set("from", newRange.from.toISOString());
|
||||
newSearchParams.set("to", newRange.to.toISOString());
|
||||
newSearchParams.set("type", newRange.type);
|
||||
router.prefetch(`${pathname}?${newSearchParams.toString()}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
queryKey: ["analytics-range", searchParams?.toString()],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-3 flex-col lg:flex-row">
|
||||
<DateRangeSelector
|
||||
className="rounded-full"
|
||||
range={localRange}
|
||||
setRange={(newRange) => {
|
||||
setRange(newRange);
|
||||
const newSearchParams = new URLSearchParams(searchParams || {});
|
||||
newSearchParams.set("from", newRange.from.toISOString());
|
||||
newSearchParams.set("to", newRange.to.toISOString());
|
||||
newSearchParams.set("type", newRange.type);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
}}
|
||||
/>
|
||||
<IntervalSelector
|
||||
className="bg-card rounded-full"
|
||||
intervalType={localInterval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setInterval(newInterval);
|
||||
const newSearchParams = new URLSearchParams(searchParams || {});
|
||||
newSearchParams.set("interval", newInterval);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useResponsiveSearchParams,
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
type DurationId,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
|
||||
|
||||
export function ResponsiveTimeFilters(props: { defaultRange: DurationId }) {
|
||||
const responsiveSearchParams = useResponsiveSearchParams();
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
const { range, interval } = getFiltersFromSearchParams({
|
||||
defaultRange: props.defaultRange,
|
||||
from: responsiveSearchParams.from,
|
||||
interval: responsiveSearchParams.interval,
|
||||
to: responsiveSearchParams.to,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-3 flex-col lg:flex-row">
|
||||
<DateRangeSelector
|
||||
className="rounded-full"
|
||||
range={range}
|
||||
setRange={(newRange) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
const newParams = {
|
||||
...v,
|
||||
from: normalizeTimeISOString(newRange.from),
|
||||
to: normalizeTimeISOString(newRange.to),
|
||||
};
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<IntervalSelector
|
||||
className="bg-card rounded-full"
|
||||
intervalType={interval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
const newParams = {
|
||||
...v,
|
||||
interval: newInterval,
|
||||
};
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProductSKU } from "@/types/billing";
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
import { CheckIcon, DollarSignIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { CheckoutButton } from "@/components/billing/billing";
|
||||
import { RenewSubscriptionButton } from "@/components/billing/renew-subscription/renew-subscription-button";
|
||||
import { RenewSubscriptionButton } from "@/components/billing/renew-subscription-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
+3
-3
@@ -5,12 +5,12 @@ import { RefreshCwIcon } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { reSubscribePlan } from "@/actions/billing";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { pollWithTimeout } from "../../../utils/pollWithTimeout";
|
||||
import { tryCatch } from "../../../utils/try-catch";
|
||||
import { pollWithTimeout } from "@/utils/pollWithTimeout";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
|
||||
export function RenewSubscriptionButton(props: {
|
||||
teamId: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
@@ -10,13 +10,14 @@ interface SignatureOption {
|
||||
|
||||
interface SignatureSelectorProps {
|
||||
options: SignatureOption[];
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
value: string | string[];
|
||||
onChange: (val: string | string[]) => void;
|
||||
setAbi?: (abi: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
secondaryTextFormatter?: (sig: SignatureOption) => string;
|
||||
className?: string;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
export function SignatureSelector({
|
||||
@@ -28,6 +29,7 @@ export function SignatureSelector({
|
||||
disabled,
|
||||
secondaryTextFormatter,
|
||||
className,
|
||||
multiSelect = false,
|
||||
}: SignatureSelectorProps) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -42,38 +44,96 @@ export function SignatureSelector({
|
||||
}));
|
||||
}, [options, secondaryTextFormatter]);
|
||||
|
||||
// Check if the current value is a custom value (not in options)
|
||||
const isCustomValue = value && !options.some((opt) => opt.value === value);
|
||||
|
||||
// Add the custom value as an option if needed
|
||||
const allOptions = useMemo(() => {
|
||||
if (isCustomValue && value) {
|
||||
return [...formattedOptions, { label: value, value }];
|
||||
// Handle both single and multi-select values
|
||||
const currentValues = useMemo((): string[] => {
|
||||
if (multiSelect) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(val): val is string =>
|
||||
val !== undefined && val !== null && val !== "",
|
||||
);
|
||||
} else {
|
||||
return value ? [value] : [];
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 && value[0] ? [value[0]] : [];
|
||||
} else {
|
||||
return value ? [value] : [];
|
||||
}
|
||||
}
|
||||
return formattedOptions;
|
||||
}, [formattedOptions, isCustomValue, value]);
|
||||
}, [value, multiSelect]);
|
||||
|
||||
// Single-select MultiSelect wrapper
|
||||
// Check if the current values include custom values (not in options)
|
||||
const customValues = useMemo((): string[] => {
|
||||
return currentValues.filter(
|
||||
(val): val is string =>
|
||||
val !== undefined &&
|
||||
val !== null &&
|
||||
val !== "" &&
|
||||
!options.some((opt) => opt.value === val),
|
||||
);
|
||||
}, [currentValues, options]);
|
||||
|
||||
// Add the custom values as options if needed
|
||||
const allOptions = useMemo(() => {
|
||||
const customOptions = customValues.map((val) => ({
|
||||
label: val,
|
||||
value: val,
|
||||
}));
|
||||
return [...formattedOptions, ...customOptions];
|
||||
}, [formattedOptions, customValues]);
|
||||
|
||||
// Multi-select or single-select MultiSelect wrapper
|
||||
const handleSelectedValuesChange = useCallback(
|
||||
(selected: string[]) => {
|
||||
// Always use the last selected value for single-select behavior
|
||||
const selectedValue =
|
||||
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
|
||||
onChange(selectedValue);
|
||||
const found = options.find((opt) => opt.value === selectedValue);
|
||||
if (setAbi) {
|
||||
setAbi(found?.abi || "");
|
||||
if (multiSelect) {
|
||||
// Multi-select behavior
|
||||
onChange(selected);
|
||||
// For multi-select, we'll use the ABI from the first selected option that has one
|
||||
const firstOptionWithAbi = selected.find((selectedValue) => {
|
||||
const found = options.find((opt) => opt.value === selectedValue);
|
||||
return found?.abi;
|
||||
});
|
||||
if (setAbi && firstOptionWithAbi) {
|
||||
const found = options.find((opt) => opt.value === firstOptionWithAbi);
|
||||
setAbi(found?.abi || "");
|
||||
}
|
||||
} else {
|
||||
// Single-select behavior (maintain backward compatibility)
|
||||
const selectedValue =
|
||||
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
|
||||
onChange(selectedValue);
|
||||
const found = options.find((opt) => opt.value === selectedValue);
|
||||
if (setAbi) {
|
||||
setAbi(found?.abi || "");
|
||||
}
|
||||
}
|
||||
setSearchValue("");
|
||||
},
|
||||
[onChange, setAbi, options],
|
||||
[onChange, setAbi, options, multiSelect],
|
||||
);
|
||||
|
||||
// Handle custom value entry
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && searchValue.trim()) {
|
||||
if (!options.some((opt) => opt.value === searchValue.trim())) {
|
||||
onChange(searchValue.trim());
|
||||
if (multiSelect) {
|
||||
// Add to existing values for multi-select
|
||||
const currentArray = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const filteredArray = currentArray.filter(
|
||||
(val): val is string => val !== undefined && val !== null,
|
||||
);
|
||||
const newValues = [...filteredArray, searchValue.trim()];
|
||||
onChange(newValues);
|
||||
} else {
|
||||
// Replace value for single-select
|
||||
onChange(searchValue.trim());
|
||||
}
|
||||
if (setAbi) setAbi("");
|
||||
setSearchValue("");
|
||||
// Optionally blur input
|
||||
@@ -106,7 +166,7 @@ export function SignatureSelector({
|
||||
customSearchInput={customSearchInput}
|
||||
customTrigger={null}
|
||||
disabled={disabled}
|
||||
maxCount={1}
|
||||
maxCount={multiSelect ? 100 : 1}
|
||||
onSelectedValuesChange={handleSelectedValuesChange}
|
||||
options={allOptions}
|
||||
overrideSearchFn={(option, searchTerm) =>
|
||||
@@ -116,11 +176,13 @@ export function SignatureSelector({
|
||||
placeholder={placeholder}
|
||||
renderOption={(option) => <span>{option.label}</span>}
|
||||
searchPlaceholder={placeholder}
|
||||
selectedValues={value ? [value] : []}
|
||||
selectedValues={currentValues}
|
||||
/>
|
||||
{isCustomValue && (
|
||||
{customValues.length > 0 && (
|
||||
<div className="mt-2 rounded border border-warning-200 bg-warning-50 px-2 py-1 text-warning-700 text-xs">
|
||||
You entered a custom signature. Please provide the ABI below.
|
||||
{multiSelect
|
||||
? `You entered ${customValues.length} custom signature${customValues.length > 1 ? "s" : ""}. Please provide the ABI below.`
|
||||
: "You entered a custom signature. Please provide the ABI below."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -9,10 +9,10 @@ import type { TokenMetadata } from "@/api/universal-bridge/tokens";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { fallbackChainIcon } from "@/constants/chain";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useTokensData } from "@/hooks/tokens";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fallbackChainIcon } from "@/utils/chain-icons";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { Spinner } from "../ui/Spinner/Spinner";
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
{!hideTabs && (
|
||||
<TabButtons
|
||||
hideBottomLine={!!onlyTabs}
|
||||
tabClassName="text-sm gap-2 !text-sm"
|
||||
tabContainerClassName="px-3 pt-1.5 gap-0.5"
|
||||
tabIconClassName="size-4"
|
||||
tabs={environments.map((env) => ({
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
|
||||
export function DismissibleAlert(props: {
|
||||
export function DismissibleAlert(
|
||||
props: {
|
||||
title: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
className?: string;
|
||||
description: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
} & (
|
||||
| {
|
||||
preserveState: true;
|
||||
localStorageId: string;
|
||||
}
|
||||
| {
|
||||
preserveState: false;
|
||||
}
|
||||
),
|
||||
) {
|
||||
if (props.preserveState) {
|
||||
return <DismissibleAlertWithLocalStorage {...props} />;
|
||||
}
|
||||
|
||||
return <DismissibleAlertWithoutLocalStorage {...props} />;
|
||||
}
|
||||
|
||||
function DismissibleAlertWithLocalStorage(props: {
|
||||
title: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
localStorageId: string;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useLocalStorage(
|
||||
@@ -17,19 +44,48 @@ export function DismissibleAlert(props: {
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return <AlertUI {...props} onClose={() => setIsVisible(false)} />;
|
||||
}
|
||||
|
||||
function DismissibleAlertWithoutLocalStorage(props: {
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return <AlertUI {...props} onClose={() => setIsVisible(false)} />;
|
||||
}
|
||||
|
||||
function AlertUI(props: {
|
||||
title: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card p-4">
|
||||
<Button
|
||||
aria-label="Close alert"
|
||||
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
|
||||
onClick={() => setIsVisible(false)}
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="mb-0.5 font-semibold">{props.title} </h2>
|
||||
<div className="text-muted-foreground text-sm">{props.description}</div>
|
||||
<div className={props.className}>
|
||||
<div className="relative rounded-lg border border-border bg-card p-4 lg:p-6 overflow-hidden">
|
||||
<Button
|
||||
aria-label="Close alert"
|
||||
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
|
||||
onClick={props.onClose}
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
{props.header}
|
||||
<h2 className="mb-0.5 font-semibold">{props.title} </h2>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { FundWalletModal } from "./index";
|
||||
|
||||
const meta: Meta<typeof FundWalletModal> = {
|
||||
title: "Blocks/FundWalletModal",
|
||||
component: Variant,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThirdwebProvider>
|
||||
<div className="p-10">
|
||||
<ConnectButton client={storybookThirdwebClient} />
|
||||
<div className="h-4" />
|
||||
<Story />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Test: Story = {
|
||||
args: {},
|
||||
};
|
||||
function Variant() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button type="button" onClick={() => setIsOpen(true)}>
|
||||
Open
|
||||
</Button>
|
||||
|
||||
<FundWalletModal
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="This is a title"
|
||||
description="This is a description"
|
||||
recipientAddress="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
|
||||
client={storybookThirdwebClient}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { CheckoutWidget, useActiveWalletChain } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
reportFundWalletFailed,
|
||||
reportFundWalletSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { TokenSelector } from "@/components/blocks/TokenSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DecimalInput } from "@/components/ui/decimal-input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { WalletAddress } from "../wallet-address";
|
||||
|
||||
const formSchema = z.object({
|
||||
chainId: z.number({
|
||||
required_error: "Chain is required",
|
||||
}),
|
||||
token: z.object(
|
||||
{
|
||||
chainId: z.number(),
|
||||
address: z.string(),
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
decimals: z.number(),
|
||||
},
|
||||
{
|
||||
required_error: "Token is required",
|
||||
},
|
||||
),
|
||||
amount: z
|
||||
.string()
|
||||
.min(1, "Amount is required")
|
||||
.refine((value) => {
|
||||
const num = Number(value);
|
||||
return !Number.isNaN(num) && num > 0;
|
||||
}, "Amount must be greater than 0"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type FundWalletModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
recipientAddress: string;
|
||||
client: ThirdwebClient;
|
||||
defaultChainId?: number;
|
||||
checkoutWidgetTitle?: string;
|
||||
};
|
||||
|
||||
export function FundWalletModal(props: FundWalletModalProps) {
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className="p-0 gap-0 !w-full !max-w-md">
|
||||
<FundWalletModalContent
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
recipientAddress={props.recipientAddress}
|
||||
client={props.client}
|
||||
defaultChainId={props.defaultChainId}
|
||||
checkoutWidgetTitle={props.checkoutWidgetTitle}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FundWalletModalContent(props: FundWalletModalProps) {
|
||||
const [step, setStep] = useState<"form" | "checkout">("form");
|
||||
const activeChain = useActiveWalletChain();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
defaultValues: {
|
||||
chainId: props.defaultChainId || activeChain?.id || 1,
|
||||
token: undefined,
|
||||
amount: "0.1",
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const selectedChainId = form.watch("chainId");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{step === "form" ? (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(() => {
|
||||
setStep("checkout");
|
||||
})}
|
||||
>
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
<DialogDescription>{props.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-4 lg:px-6 pb-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium"> Recipient</h3>
|
||||
<WalletAddress
|
||||
address={props.recipientAddress}
|
||||
client={props.client}
|
||||
className="py-1 h-auto"
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chainId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
className="bg-card"
|
||||
chainId={field.value}
|
||||
onChange={(token) => {
|
||||
field.onChange(token);
|
||||
form.resetField("token", {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
}}
|
||||
client={props.client}
|
||||
placeholder="Select a chain"
|
||||
disableDeprecated
|
||||
disableTestnets
|
||||
disableChainId
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<TokenSelector
|
||||
className="bg-card"
|
||||
disableAddress
|
||||
selectedToken={
|
||||
field.value
|
||||
? {
|
||||
chainId: field.value.chainId,
|
||||
address: field.value.address,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={field.onChange}
|
||||
chainId={selectedChainId}
|
||||
client={props.client}
|
||||
placeholder="Select a token"
|
||||
enabled={!!selectedChainId}
|
||||
addNativeTokenIfMissing={true}
|
||||
showCheck={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
className="bg-card !text-xl h-auto font-semibold"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="0.1"
|
||||
/>
|
||||
{form.watch("token") && (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
{form.watch("token").symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 lg:p-6 bg-card border-t rounded-b-lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="gap-2">
|
||||
Next
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<div>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
<DialogDescription>{props.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<CheckoutWidget
|
||||
client={props.client}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(form.getValues("chainId"))}
|
||||
tokenAddress={form.getValues("token").address as `0x${string}`}
|
||||
amount={form.getValues("amount")}
|
||||
seller={props.recipientAddress as `0x${string}`}
|
||||
showThirdwebBranding={false}
|
||||
className="!w-full !max-w-full !min-w-0 !rounded-b-none !border-none"
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
name={props.checkoutWidgetTitle}
|
||||
onSuccess={() => {
|
||||
reportFundWalletSuccessful();
|
||||
}}
|
||||
onError={(error) => {
|
||||
reportFundWalletFailed({
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 lg:p-6 bg-card border-t rounded-b-lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep("form")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="outline"
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
popoverContentClassName,
|
||||
showSelectedValuesInModal = false,
|
||||
customSearchInput,
|
||||
customTrigger,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -144,13 +145,18 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
// scroll to top when options change
|
||||
const popoverElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter out customTrigger from props to avoid passing it to Button
|
||||
const buttonProps = Object.fromEntries(
|
||||
Object.entries(props).filter(([key]) => key !== "customTrigger"),
|
||||
) as React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
return (
|
||||
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{props.customTrigger || (
|
||||
{customTrigger || (
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...buttonProps}
|
||||
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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { CrownIcon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { TeamPlanBadge } from "@/components/blocks/TeamPlanBadge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -192,11 +192,11 @@ function WalletAvatar(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("size-6 overflow-hidden rounded-full", props.iconClassName)}
|
||||
className={cn("size-5 overflow-hidden rounded-full", props.iconClassName)}
|
||||
>
|
||||
{resolvedAvatarSrc ? (
|
||||
<Img
|
||||
className={cn("size-6 object-cover", props.iconClassName)}
|
||||
className={cn("size-5 object-cover", props.iconClassName)}
|
||||
src={resolvedAvatarSrc}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -20,6 +19,7 @@ import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { popularChains } from "@/constants/popularChains";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useFavoriteChainIds } from "@/hooks/favorite-chains";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import {
|
||||
addRecentlyUsedChainId,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import type { ProjectContract } from "@/api/getProjectContracts";
|
||||
import type { ProjectContract } from "@/api/project/getProjectContracts";
|
||||
import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { ContractTableUI } from "./contract-table";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ProjectContract } from "@/api/getProjectContracts";
|
||||
import type { ProjectContract } from "@/api/project/getProjectContracts";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { NetworkSelectDropdown } from "@/components/contract-components/tables/NetworkSelectDropdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -14,11 +14,11 @@ import * as ERC1155Ext from "thirdweb/extensions/erc1155";
|
||||
import * as ERC4337Ext from "thirdweb/extensions/erc4337";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { toFunctionSelector } from "thirdweb/utils";
|
||||
import { getContractFunctionsFromAbi } from "@/api/contract/getContractFunctionsFromAbi";
|
||||
import {
|
||||
type CodeEnvironment,
|
||||
CodeSegment,
|
||||
} from "@/components/blocks/code/code-segment.client";
|
||||
import { getContractFunctionsFromAbi } from "@/components/contract-components/getContractFunctionsFromAbi";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
@@ -743,7 +743,6 @@ export function CodeOverview(props: {
|
||||
<div className="bg-background">
|
||||
<TabButtons
|
||||
tabs={tabList}
|
||||
tabClassName="!text-sm"
|
||||
tabContainerClassName="px-3 pt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+81
-16
@@ -1,19 +1,20 @@
|
||||
import { moduleToBase64 } from "app/(app)/(dashboard)/published-contract/utils/module-base-64";
|
||||
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { fetchPublishedContractVersion } from "@/components/contract-components/fetch-contracts-with-versions";
|
||||
import { resolveAvatar } from "thirdweb/extensions/ens";
|
||||
import { fetchPublishedContractVersion } from "@/api/contract/fetch-contracts-with-versions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveEns } from "@/lib/ens";
|
||||
import { replaceDeployerAddress } from "@/lib/publisher-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { ContractPublisher } from "../../../../app/(app)/(dashboard)/explore/components/publisher";
|
||||
import { ContractPublisher } from "./contract-publisher";
|
||||
|
||||
interface ContractCardProps {
|
||||
type ContractCardProps = {
|
||||
publisher: string;
|
||||
contractId: string;
|
||||
titleOverride?: string;
|
||||
@@ -29,7 +30,7 @@ interface ContractCardProps {
|
||||
moduleId: string;
|
||||
version?: string;
|
||||
}[];
|
||||
}
|
||||
};
|
||||
|
||||
function getContractUrl(
|
||||
{
|
||||
@@ -74,6 +75,72 @@ function getContractUrl(
|
||||
return replaceDeployerAddress(pathName);
|
||||
}
|
||||
|
||||
const cached_fetchPublishedContractVersion = unstable_cache(
|
||||
async (publisher: string, contractId: string, version: string = "latest") => {
|
||||
const result = await fetchPublishedContractVersion(
|
||||
publisher,
|
||||
contractId,
|
||||
serverThirdwebClient,
|
||||
version,
|
||||
).catch(() => null);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publisherEnsAndAvatar = result.publisher
|
||||
? await cached_resolvePublisherEnsAndAvatar(result.publisher)
|
||||
: undefined;
|
||||
|
||||
// Note: Do not return BigInt - it can't be serialized and cached by unstable_cache and will throw an error
|
||||
return {
|
||||
name: result.name,
|
||||
displayName: result.displayName,
|
||||
description: result.description,
|
||||
publisher: {
|
||||
address: result.publisher,
|
||||
ensName: publisherEnsAndAvatar?.ensName,
|
||||
ensAvatar: publisherEnsAndAvatar?.ensAvatar,
|
||||
},
|
||||
version: result.version,
|
||||
audit: result.audit,
|
||||
};
|
||||
},
|
||||
["fetchPublishedContractVersion"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
const cached_resolvePublisherEnsAndAvatar = unstable_cache(
|
||||
async (_addressOrEns: string) => {
|
||||
const addressOrEns = replaceDeployerAddress(_addressOrEns);
|
||||
const [ensNameInfo, ensAvatar] = await Promise.allSettled([
|
||||
resolveEns(addressOrEns, serverThirdwebClient),
|
||||
resolveAvatar({
|
||||
client: serverThirdwebClient,
|
||||
name: addressOrEns,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ensName:
|
||||
ensNameInfo.status === "fulfilled"
|
||||
? ensNameInfo.value?.ensName
|
||||
: undefined,
|
||||
address:
|
||||
ensNameInfo.status === "fulfilled"
|
||||
? ensNameInfo.value?.address
|
||||
: undefined,
|
||||
ensAvatar: ensAvatar.status === "fulfilled" ? ensAvatar.value : undefined,
|
||||
};
|
||||
},
|
||||
["resolvePublisherEnsAndAvatar"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export async function ContractCard({
|
||||
publisher,
|
||||
contractId,
|
||||
@@ -83,12 +150,11 @@ export async function ContractCard({
|
||||
modules = [],
|
||||
isBeta,
|
||||
}: ContractCardProps) {
|
||||
const publishedContractResult = await fetchPublishedContractVersion(
|
||||
const publishedContractResult = await cached_fetchPublishedContractVersion(
|
||||
publisher,
|
||||
contractId,
|
||||
serverThirdwebClient,
|
||||
version,
|
||||
).catch(() => null);
|
||||
);
|
||||
|
||||
if (!publishedContractResult) {
|
||||
return null;
|
||||
@@ -186,13 +252,12 @@ export async function ContractCard({
|
||||
!modules?.length && "mt-auto",
|
||||
)}
|
||||
>
|
||||
{publishedContractResult.publisher && (
|
||||
<ClientOnly ssr={<Skeleton className="size-5 rounded-full" />}>
|
||||
<ContractPublisher
|
||||
addressOrEns={publishedContractResult.publisher}
|
||||
client={getClientThirdwebClient()}
|
||||
/>
|
||||
</ClientOnly>
|
||||
{publishedContractResult.publisher.address && (
|
||||
<ContractPublisher
|
||||
address={publishedContractResult.publisher.address}
|
||||
ensName={publishedContractResult.publisher.ensName || undefined}
|
||||
ensAvatar={publishedContractResult.publisher.ensAvatar || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Blobbie } from "thirdweb/react";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { shortenIfAddress } from "@/utils/usedapp-external";
|
||||
|
||||
export function ContractPublisher(props: {
|
||||
ensName: string | undefined;
|
||||
address: string;
|
||||
ensAvatar: string | undefined;
|
||||
}) {
|
||||
const displayName = props.ensName || props.address;
|
||||
return (
|
||||
<Link
|
||||
className="flex shrink-0 items-center gap-1.5 hover:underline"
|
||||
href={`/${displayName}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Img
|
||||
className="size-5 rounded-full object-cover"
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
client: getClientThirdwebClient(),
|
||||
uri: props.ensAvatar,
|
||||
}) || ""
|
||||
}
|
||||
fallback={<Blobbie address={props.address} />}
|
||||
/>
|
||||
|
||||
<span className="text-xs"> {shortenIfAddress(displayName)}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { fetchDeployMetadata } from "@/components/contract-components/fetchDeployMetadata";
|
||||
import { fetchDeployMetadata } from "@/api/contract/fetchDeployMetadata";
|
||||
import { ContractIdImage } from "@/components/contract-components/shared/contract-id-image";
|
||||
import {
|
||||
Table,
|
||||
|
||||
@@ -332,7 +332,6 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
<div className="flex flex-col h-full relative">
|
||||
<TabButtons
|
||||
tabContainerClassName="px-3 pt-2"
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
...(viewFunctions.length > 0
|
||||
? [
|
||||
|
||||
@@ -30,7 +30,6 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { PlainTextCodeBlock } from "@/components/ui/code/plaintext-code";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -414,14 +413,13 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<>
|
||||
<h3 className="text-sm font-medium">Error</h3>
|
||||
<InlineCode
|
||||
className="relative w-full whitespace-pre-wrap rounded-md border border-border p-4 text-red-500"
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
code={formatError(error as any)}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Error</h3>
|
||||
<PlainTextCodeBlock
|
||||
className="text-red-500 bg-background"
|
||||
code={formatError(error)}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
) : readLoading ? (
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
) : formattedResponseData ? (
|
||||
|
||||
+2
-97
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
|
||||
import { GradientAvatar } from "@/components/blocks/avatar/gradient-avatar";
|
||||
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -17,98 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Fieldset } from "./common";
|
||||
|
||||
export type MinimalTeam = Pick<Team, "id" | "name" | "image" | "slug">;
|
||||
export type MinimalProject = Pick<Project, "id" | "name" | "image" | "slug">;
|
||||
|
||||
export type TeamAndProjectSelection = {
|
||||
team: MinimalTeam | undefined;
|
||||
project: MinimalProject | undefined;
|
||||
};
|
||||
|
||||
export type MinimalTeamsAndProjects = Array<{
|
||||
team: MinimalTeam;
|
||||
projects: MinimalProject[];
|
||||
}>;
|
||||
|
||||
type TeamAndProjectSelectorProps = {
|
||||
teamsAndProjects: MinimalTeamsAndProjects;
|
||||
selection: TeamAndProjectSelection;
|
||||
enabled: boolean;
|
||||
onSelectionChange: (selection: TeamAndProjectSelection) => void;
|
||||
client: ThirdwebClient;
|
||||
onSetEnabled: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function AddToProjectCardUI(props: TeamAndProjectSelectorProps) {
|
||||
const selectedTeam = props.teamsAndProjects.find(
|
||||
(t) => t.team.id === props.selection.team?.id,
|
||||
);
|
||||
|
||||
const isProjectSelected = props.selection.project && props.enabled;
|
||||
|
||||
return (
|
||||
<Fieldset
|
||||
description="Save the deployed contract in a project's contract list on thirdweb dashboard"
|
||||
headerChildren={
|
||||
<div className="absolute top-4 right-4 lg:top-6 lg:right-6">
|
||||
<Checkbox
|
||||
checked={props.enabled}
|
||||
className="size-5"
|
||||
onCheckedChange={(checked) => {
|
||||
props.onSetEnabled(!!checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
headerClassName="pr-14"
|
||||
legend="Add To Project"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Team & Project selectors */}
|
||||
{props.enabled && (
|
||||
<AddToProjectSelector
|
||||
client={props.client}
|
||||
onSelectionChange={props.onSelectionChange}
|
||||
selection={props.selection}
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No projects alert */}
|
||||
{selectedTeam?.projects.length === 0 && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle> Selected team has no projects </AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnderlineLink
|
||||
className="inline-flex items-center gap-2"
|
||||
href={`/team/${selectedTeam.team.slug}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create Project <ExternalLinkIcon className="size-3.5" />
|
||||
</UnderlineLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* No project selected alert */}
|
||||
{!isProjectSelected && (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>No Project Selected</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deployed contract will not be saved in thirdweb dashboard
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
import type { MinimalTeamsAndProjects, TeamAndProjectSelection } from "./types";
|
||||
|
||||
function SlashSeparator() {
|
||||
return <div className="mx-2 h-5 w-[2px] rotate-[25deg] bg-foreground/20" />;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
|
||||
export type MinimalTeam = Pick<Team, "id" | "name" | "image" | "slug">;
|
||||
export type MinimalProject = Pick<Project, "id" | "name" | "image" | "slug">;
|
||||
|
||||
export type TeamAndProjectSelection = {
|
||||
team: MinimalTeam | undefined;
|
||||
project: MinimalProject | undefined;
|
||||
};
|
||||
|
||||
export type MinimalTeamsAndProjects = Array<{
|
||||
team: MinimalTeam;
|
||||
projects: MinimalProject[];
|
||||
}>;
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { NFTMetadata } from "thirdweb/utils";
|
||||
import { NFTMediaWithEmptyState } from "@/components/blocks/nft-media";
|
||||
|
||||
interface MediaCellProps {
|
||||
cell: {
|
||||
value: NFTMetadata;
|
||||
};
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const MediaCell: React.FC<MediaCellProps> = ({ cell, client }) => {
|
||||
const nftMetadata = cell.value;
|
||||
|
||||
return (
|
||||
<NFTMediaWithEmptyState
|
||||
className="pointer-events-none"
|
||||
client={client}
|
||||
height="120px"
|
||||
metadata={nftMetadata}
|
||||
requireInteraction
|
||||
width="120px"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
import type { Abi } from "abitype";
|
||||
import type { SourceFile } from "@/components/contract-components/types";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import type { SourceFile } from "../types";
|
||||
|
||||
export function SourcesAccordion({
|
||||
sources,
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Abi } from "abitype";
|
||||
import type { SourceFile } from "@/components/contract-components/types";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import type { SourceFile } from "../types";
|
||||
import { SourcesAccordion } from "./sources-accordion";
|
||||
|
||||
interface SourcesPanelProps {
|
||||
+36
-35
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { SearchIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import type { SearchType } from "./types";
|
||||
|
||||
export function AdvancedSearchInput(props: {
|
||||
@@ -41,56 +41,57 @@ export function AdvancedSearchInput(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={searchType}
|
||||
onValueChange={(value) => setSearchType(value as SearchType)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] bg-card">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="id">ID</SelectItem>
|
||||
<SelectItem value="address">Address</SelectItem>
|
||||
<SelectItem value="externalWallet">External Wallet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<Select
|
||||
value={searchType}
|
||||
onValueChange={(value) => setSearchType(value as SearchType)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] bg-card">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="id">ID</SelectItem>
|
||||
<SelectItem value="address">Address</SelectItem>
|
||||
<SelectItem value="externalWallet">External Wallet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex flex-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="bg-card pl-9"
|
||||
className="bg-card pl-9 border-r-0 rounded-r-none"
|
||||
placeholder={`Search by ${searchType}...`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
|
||||
{props.hasResults && (
|
||||
<div className="absolute top-1/2 -translate-y-1/2 right-2 ">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClear}
|
||||
className="p-1 h-auto"
|
||||
>
|
||||
<XIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
variant="outline"
|
||||
disabled={!query.trim() || props.isLoading}
|
||||
size="sm"
|
||||
className="rounded-l-none gap-2 bg-card disabled:opacity-100"
|
||||
>
|
||||
{props.isLoading ? "Searching..." : "Search"}
|
||||
{props.isLoading && <Spinner className="size-4" />}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.hasResults && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="gap-2"
|
||||
>
|
||||
Clear Search & Return to List
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+32
-9
@@ -12,6 +12,7 @@ import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
ToolTipLabel,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
useAllEmbeddedWallets,
|
||||
useEmbeddedWallets,
|
||||
} from "@/hooks/useEmbeddedWallets";
|
||||
import { CopyTextButton } from "../ui/CopyTextButton";
|
||||
import { AdvancedSearchInput } from "./AdvancedSearchInput";
|
||||
import { SearchResults } from "./SearchResults";
|
||||
import { searchUsers } from "./searchUsers";
|
||||
@@ -57,7 +59,24 @@ export function InAppWalletUsersPageContent(
|
||||
columnHelper.accessor("linkedAccounts", {
|
||||
cell: (cell) => {
|
||||
const identifier = getUserIdentifier(cell.getValue());
|
||||
return <span className="text-sm">{identifier}</span>;
|
||||
|
||||
if (!identifier) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyTextButton
|
||||
textToShow={
|
||||
identifier.length > 30
|
||||
? `${identifier.slice(0, 30)}...`
|
||||
: identifier
|
||||
}
|
||||
textToCopy={identifier}
|
||||
tooltip="Copy User Identifier"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
header: "User Identifier",
|
||||
@@ -77,7 +96,7 @@ export function InAppWalletUsersPageContent(
|
||||
cell: (cell) => {
|
||||
const externalWallets = getExternalWallets(cell.getValue());
|
||||
if (externalWallets.length === 0) {
|
||||
return <span className="text-muted-foreground text-xs">None</span>;
|
||||
return <span className="text-muted-foreground text-sm">None</span>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -111,9 +130,14 @@ export function InAppWalletUsersPageContent(
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{format(new Date(value), "MMM dd, yyyy")}
|
||||
</span>
|
||||
<ToolTipLabel
|
||||
label={format(new Date(value), "MMM dd, yyyy 'at' h:mm:ss a zzz")}
|
||||
hoverable
|
||||
>
|
||||
<span className="text-sm">
|
||||
{format(new Date(value), "MMM dd, yyyy")}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
},
|
||||
header: "Created",
|
||||
@@ -215,7 +239,7 @@ export function InAppWalletUsersPageContent(
|
||||
return {
|
||||
address: row.wallets[0]?.address || "Uninitialized",
|
||||
created: row.wallets[0]?.createdAt
|
||||
? format(new Date(row.wallets[0].createdAt), "MMM dd, yyyy")
|
||||
? new Date(row.wallets[0].createdAt).toISOString()
|
||||
: "Wallet not created yet",
|
||||
external_wallets: externalWalletAddresses || "None",
|
||||
login_methods: row.linkedAccounts.map((acc) => acc.type).join(", "),
|
||||
@@ -243,7 +267,7 @@ export function InAppWalletUsersPageContent(
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<div className="flex flex-col md:flex-row lg:items-center justify-end gap-3">
|
||||
<div className="w-full max-w-lg">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
@@ -253,10 +277,9 @@ export function InAppWalletUsersPageContent(
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2"
|
||||
className="gap-2 bg-card"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { popularChains } from "@/constants/popularChains";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useActiveChainAsDashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { useFavoriteChainIds } from "@/hooks/favorite-chains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
recentlyUsedChainIdsStore,
|
||||
type StoredChain,
|
||||
} from "@/stores/chainStores";
|
||||
import { useFavoriteChainIds } from "../../../app/(app)/(dashboard)/(chain)/components/client/star-button";
|
||||
import { mapV4ChainToV5Chain } from "../../utils/map-chains";
|
||||
import { getSDKTheme } from "../../utils/sdk-component-theme";
|
||||
import { CustomChainRenderer } from "./CustomChainRenderer";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getFaucetClaimAmount } from "@app/api/testnet-faucet/claim/claim-amount";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { FaucetButton } from "app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton";
|
||||
import { GiftIcon } from "app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/icons/GiftIcon";
|
||||
@@ -49,6 +48,7 @@ import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChainMetadataWithServices, ChainServices } from "@/types/chain";
|
||||
import { getFaucetClaimAmount } from "@/utils/faucet";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
const GAS_FREE_CHAINS = [
|
||||
|
||||
@@ -79,7 +79,6 @@ export function DatePickerWithRange(props: {
|
||||
<div className={cn("px-4", !props.header && "py-4")}>
|
||||
<TabButtons
|
||||
activeTabClassName="!bg-inverted !text-inverted-foreground"
|
||||
tabClassName="!text-sm"
|
||||
tabContainerClassName="gap-2"
|
||||
tabs={[
|
||||
{
|
||||
|
||||
@@ -17,9 +17,7 @@ export function DynamicHeight(props: {
|
||||
boxSizing: "border-box",
|
||||
height: height ? `${height}px` : "auto",
|
||||
overflow: "hidden",
|
||||
transition:
|
||||
props.transition ||
|
||||
"height 210ms cubic-bezier(0.175, 0.885, 0.32, 1.1)",
|
||||
transition: props.transition || "height 250ms ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -21,7 +21,11 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
{props.checked === "indeterminate" ? (
|
||||
<MinusIcon className="size-3" />
|
||||
) : (
|
||||
<CheckIcon className="size-4" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
|
||||
@@ -7,12 +7,15 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & {
|
||||
size?: "sm";
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-foreground data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
props.size === "sm" && "h-4.5 w-9",
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
@@ -20,6 +23,8 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
props.size === "sm" &&
|
||||
"h-4 w-4 translate-x-4 data-[state=checked]:translate-x-4",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TabLinks(props: {
|
||||
<Link
|
||||
aria-disabled={tab.isDisabled}
|
||||
className={cn(
|
||||
"relative h-auto rounded-lg px-3 font-normal text-muted-foreground text-sm lg:text-sm",
|
||||
"relative inline-flex h-auto items-center gap-1.5 rounded-lg font-medium hover:bg-accent !px-3",
|
||||
!tab.isActive && !tab.isDisabled && "hover:text-foreground",
|
||||
tab.isDisabled && "pointer-events-none",
|
||||
tab.isActive && "!text-foreground",
|
||||
@@ -125,7 +125,7 @@ export function TabButtons(props: {
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2 font-medium text-sm hover:bg-accent lg:px-3 lg:text-base",
|
||||
"relative inline-flex h-auto items-center gap-1.5 rounded-lg font-medium hover:bg-accent !px-3",
|
||||
!tab.isActive &&
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
tab.isDisabled && "cursor-not-allowed opacity-50",
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const COOKIE_ACTIVE_ACCOUNT = "tw_active_account";
|
||||
export const COOKIE_PREFIX_TOKEN = "tw_token_";
|
||||
|
||||
export async function getActiveAccountCookie() {
|
||||
return (await cookies()).get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
}
|
||||
|
||||
export async function getJWTCookie(address: string) {
|
||||
return (await cookies()).get(COOKIE_PREFIX_TOKEN + address)?.value;
|
||||
}
|
||||
export const LAST_USED_PROJECT_ID = "last-used-project-id";
|
||||
export const LAST_USED_TEAM_ID = "last-used-team-id";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const LAST_USED_PROJECT_ID = "last-used-project-id";
|
||||
export const LAST_USED_TEAM_ID = "last-used-team-id";
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
|
||||
// Note: Growth legacy is considered higher tier in this hierarchy
|
||||
export const planToTierRecordForGating: Record<Team["billingPlan"], number> = {
|
||||
|
||||
@@ -58,16 +58,6 @@ if (isProd && INSIGHT_SERVICE_API_KEY) {
|
||||
);
|
||||
}
|
||||
|
||||
export const MORALIS_API_KEY = process.env.MORALIS_API_KEY || "";
|
||||
|
||||
if (MORALIS_API_KEY) {
|
||||
experimental_taintUniqueValue(
|
||||
"Do not pass MORALIS_API_KEY to the client",
|
||||
process,
|
||||
MORALIS_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
export const ANALYTICS_SERVICE_URL = process.env.ANALYTICS_SERVICE_URL || "";
|
||||
|
||||
export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "";
|
||||
|
||||
@@ -6,14 +6,14 @@ import { useMemo } from "react";
|
||||
import type { ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import { getContract, resolveContractAbi } from "thirdweb/contract";
|
||||
import { isAddress, isValidENSName } from "thirdweb/utils";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { resolveEns } from "@/lib/ens";
|
||||
import {
|
||||
fetchPublishedContractVersions,
|
||||
type PublishedContractWithVersion,
|
||||
} from "../components/contract-components/fetch-contracts-with-versions";
|
||||
import { fetchPublishedContracts } from "../components/contract-components/fetchPublishedContracts";
|
||||
import { fetchPublishedContractsFromDeploy } from "../components/contract-components/fetchPublishedContractsFromDeploy";
|
||||
} from "@/api/contract/fetch-contracts-with-versions";
|
||||
import { fetchPublishedContracts } from "@/api/contract/fetchPublishedContracts";
|
||||
import { fetchPublishedContractsFromDeploy } from "@/api/contract/fetchPublishedContractsFromDeploy";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { resolveEns } from "@/lib/ens";
|
||||
|
||||
export function useAllVersions(
|
||||
publisherAddress: string | undefined,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
|
||||
async function favoriteChains() {
|
||||
const res = await apiServerProxy<{ data: string[] }>({
|
||||
method: "GET",
|
||||
pathname: "/v1/chains/favorites",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const result = res.data;
|
||||
return result.data ?? [];
|
||||
}
|
||||
|
||||
export function useFavoriteChainIds() {
|
||||
const address = useActiveAccount()?.address;
|
||||
return useQuery({
|
||||
enabled: !!address,
|
||||
queryFn: () => favoriteChains(),
|
||||
queryKey: ["favoriteChains", address],
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { rotateVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import pLimit from "p-limit";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useState } from "react";
|
||||
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
@@ -95,6 +94,7 @@ export function useCsvUpload<
|
||||
// Always gonna need the wallet address
|
||||
T extends { address: string },
|
||||
>(props: Props<T>) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rawData, setRawData] = useState<
|
||||
T[] | Array<T & { [key in string]: unknown }>
|
||||
>(props.defaultRawData || []);
|
||||
@@ -132,16 +132,29 @@ export function useCsvUpload<
|
||||
[props.csvParser],
|
||||
);
|
||||
|
||||
const [normalizeProgress, setNormalizeProgress] = useState({
|
||||
total: 0,
|
||||
current: 0,
|
||||
});
|
||||
|
||||
const normalizeQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
const limit = pLimit(50);
|
||||
const results = await Promise.all(
|
||||
rawData.map((item) => {
|
||||
return limit(() =>
|
||||
const batchSize = 50;
|
||||
const results = [];
|
||||
for (let i = 0; i < rawData.length; i += batchSize) {
|
||||
const batch = rawData.slice(i, i + batchSize);
|
||||
setNormalizeProgress({
|
||||
total: rawData.length,
|
||||
current: i,
|
||||
});
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((item) =>
|
||||
checkIsAddress({ item: item, thirdwebClient: props.client }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return {
|
||||
invalidFound: !!results.find((item) => !item?.isValid),
|
||||
result: processAirdropData(results),
|
||||
@@ -153,12 +166,18 @@ export function useCsvUpload<
|
||||
|
||||
const removeInvalid = useCallback(() => {
|
||||
const filteredData = normalizeQuery.data?.result.filter(
|
||||
({ isValid }) => isValid,
|
||||
(d) => d.isValid && d.resolvedAddress !== ZERO_ADDRESS,
|
||||
);
|
||||
// double type assertion is save here because we don't really use this variable (only check for its length)
|
||||
// Also filteredData's type is the superset of T[]
|
||||
setRawData(filteredData as unknown as T[]);
|
||||
}, [normalizeQuery.data?.result]);
|
||||
|
||||
if (filteredData && normalizeQuery.data) {
|
||||
// Directly update the query result instead of setting new state to avoid triggering refetch
|
||||
queryClient.setQueryData(["snapshot-check-isAddress", rawData], {
|
||||
...normalizeQuery.data,
|
||||
result: filteredData,
|
||||
invalidFound: false, // Since we removed all invalid items
|
||||
});
|
||||
}
|
||||
}, [normalizeQuery.data, queryClient, rawData]);
|
||||
|
||||
const processData = useCallback(
|
||||
(data: T[]) => {
|
||||
@@ -181,5 +200,6 @@ export function useCsvUpload<
|
||||
removeInvalid,
|
||||
reset,
|
||||
setFiles,
|
||||
normalizeProgress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: we only want to run effect once */
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useEffectOnce(effect: () => void) {
|
||||
const hasCalledEffect = useRef(false);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (hasCalledEffect.current) return;
|
||||
hasCalledEffect.current = true;
|
||||
effect();
|
||||
}, []);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user