Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65022174d | ||
|
|
6818cbabf2 | ||
|
|
a7fb844d87 | ||
|
|
bf431961ec | ||
|
|
c5d4cab8f8 | ||
|
|
1ee0dffec4 | ||
|
|
33611409b0 | ||
|
|
ab55aec10c | ||
|
|
ca22e4659e | ||
|
|
411895c002 | ||
|
|
cfa1c20a91 | ||
|
|
2b16f90715 | ||
|
|
e8892b152f | ||
|
|
073b25afd7 | ||
|
|
4550bb2663 | ||
|
|
f5f8a40320 | ||
|
|
3569763501 | ||
|
|
1401f8d039 | ||
|
|
1616b7f619 | ||
|
|
aa548a359f | ||
|
|
eef52dfa7e | ||
|
|
19ec571500 | ||
|
|
47dce2e05c | ||
|
|
8b4695224c | ||
|
|
94e2d5b8dd | ||
|
|
40fa323294 | ||
|
|
2cebb420e6 | ||
|
|
e89ddb24f2 | ||
|
|
86f205e2aa | ||
|
|
6c277ae676 | ||
|
|
153651df35 | ||
|
|
8ba460b6fc | ||
|
|
1e61a9edfc | ||
|
|
d03e687aa9 | ||
|
|
e1c0dbe19e | ||
|
|
3e7df081d7 | ||
|
|
b6f189c220 | ||
|
|
fb4d0a26f1 | ||
|
|
bc345371ba | ||
|
|
7a8e4e5f86 | ||
|
|
6ba3b30b60 | ||
|
|
5ad442cf0c | ||
|
|
5756c74a71 | ||
|
|
a1cff7a579 | ||
|
|
eafaefec20 | ||
|
|
b81d7a1854 | ||
|
|
21cc45adc2 | ||
|
|
a67d342779 | ||
|
|
60edce49c0 | ||
|
|
20c0dca958 | ||
|
|
4b30b847e2 | ||
|
|
b2cc17fd1e | ||
|
|
ace1dc5b09 | ||
|
|
9f79277344 | ||
|
|
6e0fd205ff | ||
|
|
69b1ee9176 | ||
|
|
0a19142a24 | ||
|
|
feb4fd0908 | ||
|
|
1d8695eb42 | ||
|
|
858dc53a25 | ||
|
|
8997fac1b0 | ||
|
|
403e4224e4 | ||
|
|
de039ef462 | ||
|
|
35fd3bcafa | ||
|
|
80a22ff45e | ||
|
|
eefa2ab424 | ||
|
|
ab9a148b75 | ||
|
|
8bbee03c77 | ||
|
|
b38604cce8 | ||
|
|
59eda22043 | ||
|
|
577636c037 | ||
|
|
1d844ac5d6 | ||
|
|
c7be92f75b | ||
|
|
79ed399e56 | ||
|
|
ded5e22bb7 | ||
|
|
5029345461 | ||
|
|
c9ccc59464 | ||
|
|
dc05f2ea3c | ||
|
|
7d9986f066 | ||
|
|
bb6c71e968 | ||
|
|
89beb3c5d3 | ||
|
|
3b98fbf8bb | ||
|
|
8b5cb47339 | ||
|
|
0760706dbb |
+1
-1
@@ -12,7 +12,7 @@ packages/eslint-config-thirdweb/ @thirdweb-dev/core-platform @edwardysun
|
||||
packages/tw-tsconfig/ @thirdweb-dev/core-platform @edwardysun
|
||||
|
||||
# apps
|
||||
apps/ @thirdweb-dev/developer-tools @thirdweb-dev/core-platform @jakubkrehel
|
||||
apps/ @thirdweb-dev/developer-tools @thirdweb-dev/core-platform
|
||||
|
||||
# .github folder + .changeset + turbo config is owned by jonas for now
|
||||
scripts/ @jnsdls @joaquim-verges
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
title: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"
|
||||
---
|
||||
<!--
|
||||
|
||||
## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"
|
||||
|
||||
If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):
|
||||
|
||||
## Notes for the reviewer
|
||||
|
||||
Anything important to call out? Be sure to also clarify these in your comments.
|
||||
|
||||
## How to test
|
||||
|
||||
Unit tests, playground, etc.
|
||||
|
||||
-->
|
||||
|
||||
Vendored
+5
-1
@@ -27,5 +27,9 @@
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
},
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "./packages/*/" },
|
||||
{ "pattern": "./apps/*/" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ module.exports = [
|
||||
// -- connect
|
||||
"/connect",
|
||||
"/connect/sign-in",
|
||||
"/connect/pay",
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// --insight
|
||||
|
||||
+44
-44
@@ -31,29 +31,29 @@
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@n8tb1t/use-scroll-position": "^2.0.3",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.5",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.3",
|
||||
"@radix-ui/react-hover-card": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-hover-card": "^1.1.5",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.3",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "1.1.5",
|
||||
"@sentry/nextjs": "8.45.1",
|
||||
"@shazow/whatsabi": "^0.18.0",
|
||||
"@tanstack/react-query": "5.62.16",
|
||||
"@radix-ui/react-tooltip": "1.1.7",
|
||||
"@sentry/nextjs": "8.52.0",
|
||||
"@shazow/whatsabi": "^0.19.0",
|
||||
"@tanstack/react-query": "5.65.1",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/functions": "^1.5.2",
|
||||
"@vercel/og": "^0.6.4",
|
||||
"abitype": "1.0.7",
|
||||
"@vercel/functions": "^1.6.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"abitype": "1.0.8",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -62,19 +62,19 @@
|
||||
"date-fns": "4.1.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "11.15.0",
|
||||
"framer-motion": "12.0.6",
|
||||
"fuse.js": "7.0.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.468.0",
|
||||
"next": "15.1.3",
|
||||
"lucide-react": "0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.67.1",
|
||||
"qrcode": "^1.5.3",
|
||||
@@ -83,17 +83,17 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.14.1",
|
||||
"recharts": "2.15.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "^1.7.1",
|
||||
"sonner": "^1.7.3",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"swagger-ui-react": "^5.18.2",
|
||||
"swagger-ui-react": "^5.18.3",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
@@ -103,42 +103,42 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.2",
|
||||
"@next/bundle-analyzer": "15.1.0",
|
||||
"@next/eslint-plugin-next": "15.1.3",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@storybook/addon-essentials": "8.4.7",
|
||||
"@storybook/addon-interactions": "8.4.7",
|
||||
"@storybook/addon-links": "8.4.7",
|
||||
"@storybook/addon-onboarding": "8.4.7",
|
||||
"@storybook/addon-viewport": "8.4.7",
|
||||
"@storybook/blocks": "8.4.7",
|
||||
"@storybook/nextjs": "8.4.7",
|
||||
"@storybook/react": "8.4.7",
|
||||
"@storybook/test": "8.4.7",
|
||||
"@chromatic-com/storybook": "3.2.4",
|
||||
"@next/bundle-analyzer": "15.1.6",
|
||||
"@next/eslint-plugin-next": "15.1.6",
|
||||
"@playwright/test": "1.50.0",
|
||||
"@storybook/addon-essentials": "8.5.2",
|
||||
"@storybook/addon-interactions": "8.5.2",
|
||||
"@storybook/addon-links": "8.5.2",
|
||||
"@storybook/addon-onboarding": "8.5.2",
|
||||
"@storybook/addon-viewport": "8.5.2",
|
||||
"@storybook/blocks": "8.5.2",
|
||||
"@storybook/nextjs": "8.5.2",
|
||||
"@storybook/react": "8.5.2",
|
||||
"@storybook/test": "8.5.2",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "19.0.2",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"checkly": "^4.15.0",
|
||||
"checkly": "^4.19.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
|
||||
"eslint-plugin-storybook": "^0.11.1",
|
||||
"knip": "5.41.0",
|
||||
"knip": "5.43.6",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.4.49",
|
||||
"storybook": "8.4.7",
|
||||
"tailwindcss": "3.4.16",
|
||||
"typescript": "5.7.2"
|
||||
"postcss": "8.5.1",
|
||||
"storybook": "8.5.2",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
|
|
Before Width: | Height: | Size: 881 KiB After Width: | Height: | Size: 881 KiB |
@@ -3,6 +3,7 @@ import type {
|
||||
AnalyticsQueryParams,
|
||||
InAppWalletStats,
|
||||
RpcMethodStats,
|
||||
TransactionStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
@@ -77,7 +78,10 @@ export async function getUserOpUsage(
|
||||
});
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch user ops usage");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch user ops usage: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -85,6 +89,30 @@ export async function getUserOpUsage(
|
||||
return json.data as UserOpStats[];
|
||||
}
|
||||
|
||||
export async function getClientTransactions(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<TransactionStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/transactions/client?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch client transactions stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as TransactionStats[];
|
||||
}
|
||||
|
||||
export async function getRpcMethodUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<RpcMethodStats[]> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "server-only";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
@@ -21,11 +20,7 @@ export type Project = {
|
||||
};
|
||||
|
||||
export async function getProjects(teamSlug: string) {
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return [];
|
||||
@@ -46,11 +41,7 @@ export async function getProjects(teamSlug: string) {
|
||||
}
|
||||
|
||||
export async function getProject(teamSlug: string, projectSlug: string) {
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
|
||||
@@ -25,7 +25,7 @@ export type Team = {
|
||||
billingPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
billingEmail: string | null;
|
||||
growthTrialEligible: boolean | null;
|
||||
growthTrialEligible: false;
|
||||
enabledScopes: EnabledTeamScope[];
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ type Option = { label: string; value: string };
|
||||
export function MultiNetworkSelector(props: {
|
||||
selectedChainIds: number[];
|
||||
onChange: (chainIds: number[]) => void;
|
||||
disableChainId?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
@@ -53,16 +55,24 @@ export function MultiNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
/>
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
|
||||
{!props.disableChainId && (
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[idToChain],
|
||||
[idToChain, props.disableChainId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -79,6 +89,7 @@ export function MultiNetworkSelector(props: {
|
||||
disabled={allChains.length === 0}
|
||||
overrideSearchFn={searchFn}
|
||||
renderOption={renderOption}
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +101,9 @@ export function SingleNetworkSelector(props: {
|
||||
popoverContentClassName?: string;
|
||||
// if specified - only these chains will be shown
|
||||
chainIds?: number[];
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
disableChainId?: boolean;
|
||||
align?: "center" | "start" | "end";
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
@@ -140,16 +154,19 @@ export function SingleNetworkSelector(props: {
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
/>
|
||||
{chain.name}
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
<Badge variant="outline" className="gap-2 max-sm:hidden">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
|
||||
{!props.disableChainId && (
|
||||
<Badge variant="outline" className="gap-2 max-sm:hidden">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[idToChain],
|
||||
[idToChain, props.disableChainId],
|
||||
);
|
||||
|
||||
const isLoadingChains = allChains.length === 0;
|
||||
@@ -162,12 +179,15 @@ export function SingleNetworkSelector(props: {
|
||||
onValueChange={(chainId) => {
|
||||
props.onChange(Number(chainId));
|
||||
}}
|
||||
closeOnSelect={true}
|
||||
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
|
||||
overrideSearchFn={searchFn}
|
||||
renderOption={renderOption}
|
||||
className={props.className}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
disabled={isLoadingChains}
|
||||
side={props.side}
|
||||
align={props.align}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ interface SelectWithSearchProps
|
||||
) => boolean;
|
||||
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
|
||||
popoverContentClassName?: string;
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
align?: "center" | "start" | "end";
|
||||
closeOnSelect?: boolean;
|
||||
}
|
||||
|
||||
export const SelectWithSearch = React.forwardRef<
|
||||
@@ -48,6 +51,7 @@ export const SelectWithSearch = React.forwardRef<
|
||||
overrideSearchFn,
|
||||
popoverContentClassName,
|
||||
searchPlaceholder,
|
||||
closeOnSelect,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -107,7 +111,7 @@ export const SelectWithSearch = React.forwardRef<
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
@@ -118,23 +122,24 @@ export const SelectWithSearch = React.forwardRef<
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm",
|
||||
"truncate text-muted-foreground text-sm",
|
||||
selectedOption && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||
<ChevronDown className="size-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className={cn("z-[10001] p-0", popoverContentClassName)}
|
||||
align="center"
|
||||
align={props.align || "center"}
|
||||
side={props.side}
|
||||
sideOffset={10}
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
style={{
|
||||
@@ -174,7 +179,12 @@ export const SelectWithSearch = React.forwardRef<
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => onValueChange(option.value)}
|
||||
onClick={() => {
|
||||
onValueChange(option.value);
|
||||
if (closeOnSelect) {
|
||||
setIsPopoverOpen(false);
|
||||
}
|
||||
}}
|
||||
variant="ghost"
|
||||
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
|
||||
ref={
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { Check, Copy, ExternalLinkIcon } from "lucide-react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type ThirdwebClient, isAddress } from "thirdweb";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
@@ -148,21 +148,6 @@ export function WalletAddress(props: {
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
variant="upsell"
|
||||
className="flex flex-row items-center gap-2 text-sm"
|
||||
size="sm"
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://blog.thirdweb.com/changelog/introducing-the-social-sdk?ref=dashboard-social-wallet"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ToolTipLabel } from "./tooltip";
|
||||
export function CopyTextButton(props: {
|
||||
textToShow: string;
|
||||
textToCopy: string;
|
||||
tooltip: string;
|
||||
tooltip: string | undefined;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
variant?:
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { ResultItem } from "app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import type { EngineBackendWalletType } from "lib/engine";
|
||||
import { useState } from "react";
|
||||
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { EngineStatus } from "../../../app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table";
|
||||
import { engineKeys } from "../cache-keys";
|
||||
@@ -469,12 +469,12 @@ export function useEngineBackendWalletBalance(params: {
|
||||
instanceUrl: string;
|
||||
address: string;
|
||||
authToken: string;
|
||||
chainId: number;
|
||||
}) {
|
||||
const { instanceUrl, address, authToken } = params;
|
||||
const chainId = useActiveWalletChain()?.id;
|
||||
const { instanceUrl, address, authToken, chainId } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: engineKeys.backendWalletBalance(address, chainId || 1),
|
||||
queryKey: engineKeys.backendWalletBalance(address, chainId),
|
||||
queryFn: async () => {
|
||||
invariant(chainId, "chainId is required");
|
||||
|
||||
|
||||
+5
-1
@@ -4,6 +4,7 @@ import {
|
||||
toTokens,
|
||||
toUnits,
|
||||
} from "thirdweb";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
|
||||
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
@@ -71,7 +72,10 @@ export async function getClaimPhasesInLegacyFormat(
|
||||
conditions.map(async (condition) => {
|
||||
const [currencyMetadata, metadata, contractMetadata] = await Promise.all([
|
||||
await ERC20Ext.getCurrencyMetadata({
|
||||
contract: { ...options.contract, address: condition.currency },
|
||||
contract: getContract({
|
||||
...options.contract,
|
||||
address: condition.currency,
|
||||
}),
|
||||
}).then((m) => ({
|
||||
...m,
|
||||
displayValue: toTokens(condition.pricePerToken, m.decimals),
|
||||
|
||||
+5
-5
@@ -197,7 +197,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
href="/assets/examples/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
@@ -212,7 +212,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
href="/assets/examples/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
@@ -228,7 +228,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/snapshot.csv"
|
||||
href="/assets/examples/snapshot.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
@@ -243,7 +243,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
href="/assets/examples/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
@@ -262,7 +262,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
href="/assets/examples/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
|
||||
+1
@@ -323,6 +323,7 @@ export const EmbedSetup: React.FC<EmbedSetupProps> = ({
|
||||
apiKeys.refetch();
|
||||
}}
|
||||
enableNebulaServiceByDefault={false}
|
||||
teamSlug={undefined}
|
||||
/>
|
||||
|
||||
<Alert variant="warning">
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import type { Account as TWAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { CircleSlash } from "lucide-react";
|
||||
import type { ContractOptions } from "thirdweb";
|
||||
import type { ThirdwebContract } from "thirdweb/contract";
|
||||
import type { Account } from "thirdweb/wallets";
|
||||
import { ModuleCard } from "./module-card";
|
||||
import { useAllModuleContractInfo } from "./moduleContractInfo";
|
||||
|
||||
export const InstalledModulesTable = (props: {
|
||||
contract: ContractOptions;
|
||||
contract: ThirdwebContract;
|
||||
installedModules: {
|
||||
data?: string[];
|
||||
isPending: boolean;
|
||||
|
||||
+10
-7
@@ -15,8 +15,9 @@ import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type Chain,
|
||||
type ContractOptions,
|
||||
type ThirdwebClient,
|
||||
type ThirdwebContract,
|
||||
getContract,
|
||||
sendTransaction,
|
||||
waitForReceipt,
|
||||
} from "thirdweb";
|
||||
@@ -47,7 +48,7 @@ type FormData = {
|
||||
};
|
||||
|
||||
type InstallModuleFormProps = {
|
||||
contract: ContractOptions;
|
||||
contract: ThirdwebContract;
|
||||
refetchModules: () => void;
|
||||
account: Account;
|
||||
installedModules: {
|
||||
@@ -180,11 +181,13 @@ export const InstallModuleForm = (props: InstallModuleFormProps) => {
|
||||
|
||||
return Promise.all(
|
||||
moduleAddress.map(async (address) => {
|
||||
const result = await resolveImplementation({
|
||||
client,
|
||||
address,
|
||||
chain: contract.chain,
|
||||
});
|
||||
const result = await resolveImplementation(
|
||||
getContract({
|
||||
client,
|
||||
address,
|
||||
chain: contract.chain,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Failed to fetch bytecode for module");
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type ContractOptions,
|
||||
type ThirdwebContract,
|
||||
getContract,
|
||||
sendTransaction,
|
||||
waitForReceipt,
|
||||
@@ -35,7 +35,7 @@ import { useModuleContractInfo } from "./moduleContractInfo";
|
||||
|
||||
type ModuleCardProps = {
|
||||
moduleAddress: string;
|
||||
contract: ContractOptions;
|
||||
contract: ThirdwebContract;
|
||||
onRemoveModule: () => void;
|
||||
ownerAccount: Account | undefined;
|
||||
allModuleContractInfo: {
|
||||
|
||||
+5
-1
@@ -193,7 +193,11 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
Files <em>must</em> contain one .csv file with an address and
|
||||
quantity column, if the quantity column is not provided, that
|
||||
record will be flagged as invalid.
|
||||
<Link download color="primary.500" href="/airdrop.csv">
|
||||
<Link
|
||||
download
|
||||
color="primary.500"
|
||||
href="/assets/examples/airdrop.csv"
|
||||
>
|
||||
Download an example CSV
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TabLinks } from "@/components/ui/tabs";
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
|
||||
const links: {
|
||||
name: string;
|
||||
segment: string;
|
||||
}[] = [
|
||||
{ name: "Transaction Simulator", segment: "transaction-simulator" },
|
||||
{ name: "Wei Converter", segment: "wei-converter" },
|
||||
{ name: "Hex Converter", segment: "hex-converter" },
|
||||
{ name: "Unix Time Converter", segment: "unixtime-converter" },
|
||||
{ name: "Keccak-256 Converter", segment: "keccak256-converter" },
|
||||
];
|
||||
|
||||
export function ToolsTabs() {
|
||||
const layoutSegment = useSelectedLayoutSegment() || "";
|
||||
|
||||
return (
|
||||
<TabLinks
|
||||
links={links.map((tab) => ({
|
||||
name: tab.name,
|
||||
href: `/tools/${tab.segment}`,
|
||||
isActive: layoutSegment === tab.segment,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import type { Metadata } from "next";
|
||||
import { ToolsTabs } from "./components/tabs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "thirdweb Blockchain Tools",
|
||||
@@ -11,13 +11,31 @@ export default function ToolLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex h-full flex-col gap-8">
|
||||
<main className="container flex-1 pb-20">
|
||||
<div className="h-14" />
|
||||
<ToolsTabs />
|
||||
<div className="h-8" />
|
||||
<div>{children}</div>
|
||||
</main>
|
||||
</section>
|
||||
<SidebarLayout
|
||||
sidebarLinks={[
|
||||
{
|
||||
label: "Transaction Simulator",
|
||||
href: "/tools/transaction-simulator",
|
||||
},
|
||||
{
|
||||
label: "Wei Converter",
|
||||
href: "/tools/wei-converter",
|
||||
},
|
||||
{
|
||||
label: "Hex Converter",
|
||||
href: "/tools/hex-converter",
|
||||
},
|
||||
{
|
||||
label: "Unix Time Converter",
|
||||
href: "/tools/unixtime-converter",
|
||||
},
|
||||
{
|
||||
label: "Keccak-256 Converter",
|
||||
href: "/tools/keccak256-converter",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+138
-106
@@ -1,118 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import { ShareButton } from "../../components/share";
|
||||
import {
|
||||
formatDistanceToNow,
|
||||
formatISO9075,
|
||||
formatRFC3339,
|
||||
formatRFC7231,
|
||||
fromUnixTime,
|
||||
} from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const UnixTimeConverter = () => {
|
||||
const getCurrentUnixMilliseconds = () => Math.floor(new Date().getTime());
|
||||
const [unix, setUnix] = useState<number>(getCurrentUnixMilliseconds());
|
||||
export function UnixTimeConverter() {
|
||||
const [unixTime, setUnixTime] = useState(() => {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
});
|
||||
|
||||
// `unix` may be in milliseconds or seconds.
|
||||
// Treat any value < 100000000000 as seconds, otherwise milliseconds.
|
||||
const isSeconds = unix < 100000000000;
|
||||
const date = new Date(isSeconds ? unix * 1000 : unix);
|
||||
const dateLocal = date.toLocaleString(undefined, {
|
||||
timeZoneName: "short",
|
||||
});
|
||||
const dateUTC = date.toLocaleString(undefined, {
|
||||
timeZoneName: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const { date, error } = useMemo(() => {
|
||||
if (Number.isNaN(unixTime) || !Number.isInteger(unixTime)) {
|
||||
return {
|
||||
error: "Invalid Unix Timestamp",
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
const date = fromUnixTime(unixTime);
|
||||
|
||||
if (!isValidDate(date)) {
|
||||
return {
|
||||
date: null,
|
||||
error: "Invalid Unix Timestamp",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
error: null,
|
||||
};
|
||||
}, [unixTime]);
|
||||
|
||||
return (
|
||||
<div className="space-y-24">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Label htmlFor="unix-input" className="min-w-64 text-xl">
|
||||
Unix Time
|
||||
</Label>
|
||||
<Input
|
||||
id="unix-input"
|
||||
type="number"
|
||||
value={unix}
|
||||
onChange={(e) => {
|
||||
if (!Number.isNaN(e.target.valueAsNumber)) {
|
||||
setUnix(e.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter a Unix time in milliseconds or seconds"
|
||||
className="p-6 text-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Label className="min-w-64 text-xl">Time (local)</Label>
|
||||
<div>
|
||||
<CopyTextButton
|
||||
textToCopy={dateLocal}
|
||||
textToShow={dateLocal}
|
||||
tooltip="Copy"
|
||||
copyIconPosition="right"
|
||||
className="font-mono text-xl"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm italic">
|
||||
← {formatDistanceToNow(date, { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Label className="min-w-64 text-xl">Time (UTC)</Label>
|
||||
<div>
|
||||
<CopyTextButton
|
||||
textToCopy={dateUTC}
|
||||
textToShow={dateUTC}
|
||||
tooltip="Copy"
|
||||
copyIconPosition="right"
|
||||
className="font-mono text-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<ShareButton
|
||||
cta="Share on X"
|
||||
href="https://twitter.com/intent/tweet?text=Easy-to-use%20Unix%20time%20converter%20by%20thirdweb%20%F0%9F%98%8D&url=https%3A%2F%2Fthirdweb.com%2Ftools%2Funixtime-converter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader className="mb-6 border-b">
|
||||
<CardTitle className="font-bold text-2xl">
|
||||
Unix Time Converter
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-bold text-2xl">
|
||||
Convert Unix time in milliseconds or seconds
|
||||
</h1>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="unixTime">Enter Unix Timestamp</Label>
|
||||
<Input
|
||||
id="unixTime"
|
||||
type="number"
|
||||
value={unixTime}
|
||||
onChange={(e) => {
|
||||
setUnixTime(Number(e.target.value));
|
||||
}}
|
||||
placeholder="e.g., 1609459200"
|
||||
className="mt-1"
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-destructive-text text-sm">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Timestamps are often represented in Unix time for several key reasons:
|
||||
</p>
|
||||
<h2 className="font-bold text-md">Simplicity</h2>
|
||||
<p>
|
||||
Counts seconds since January 1, 1970, making it easy to understand and
|
||||
manipulate.
|
||||
</p>
|
||||
<h2 className="font-bold text-md">Consistency</h2>
|
||||
<p>
|
||||
Provides a uniform time representation across systems, unaffected by
|
||||
time zones or calendar changes.
|
||||
</p>
|
||||
<h2 className="font-bold text-md">Standardization</h2>
|
||||
<p>
|
||||
Widely supported in computing, ensuring compatibility across
|
||||
platforms.
|
||||
</p>
|
||||
<h2 className="font-bold text-md">Calculation</h2>
|
||||
<p>
|
||||
Allows straightforward arithmetic operations for time differences.
|
||||
</p>
|
||||
<h2 className="font-bold text-md">Efficiency</h2>
|
||||
<p>
|
||||
Stores time as a single integer, conserving memory and computational
|
||||
resources.
|
||||
</p>
|
||||
<h2 className="font-bold text-md">Interoperability</h2>
|
||||
<p>Facilitates seamless data exchange between systems and languages.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label>Relative Time</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date
|
||||
? formatDistanceToNow(date, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "---"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Local Time</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date ? formatLocal(date) : "---"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>UTC Time</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date ? formatUTC(date) : "---"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>ISO 8601</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date ? formatISO8601(date) : "---"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>ISO 9075</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date ? formatISO9075(date) : "---"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>RFC 7231</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date ? formatRFC7231(date) : "---"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>RFC 3339</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date ? formatRFC3339(date) : "---"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>SQL Timestamp</Label>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{date ? formatSQL(date) : "---"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function formatLocal(date: Date): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatUTC(date: Date): string {
|
||||
return date.toUTCString();
|
||||
}
|
||||
|
||||
function isValidDate(date: Date): boolean {
|
||||
return !Number.isNaN(date.getTime());
|
||||
}
|
||||
|
||||
function formatISO8601(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function formatSQL(date: Date): string {
|
||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ClientOnly } from "../../../../components/ClientOnly/ClientOnly";
|
||||
import { UnixTimeConverter } from "./components/UnixTimeConverter";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -7,5 +8,9 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
return <UnixTimeConverter />;
|
||||
return (
|
||||
<ClientOnly ssr={null}>
|
||||
<UnixTimeConverter />
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ export function AccountHeader(props: {
|
||||
// refresh projects
|
||||
router.refresh();
|
||||
}}
|
||||
teamSlug={
|
||||
createProjectDialogState.isOpen
|
||||
? createProjectDialogState.team.slug
|
||||
: undefined
|
||||
}
|
||||
enableNebulaServiceByDefault={
|
||||
createProjectDialogState.isOpen &&
|
||||
createProjectDialogState.team.enabledScopes.includes("nebula")
|
||||
|
||||
@@ -66,6 +66,14 @@ export function AccountButton(props: {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="justify-start px-3 text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
<Link href="/team/~/~/settings/billing">Manage Billing</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
|
||||
@@ -37,7 +37,7 @@ export const metadata: Metadata = {
|
||||
siteName: "thirdweb",
|
||||
images: [
|
||||
{
|
||||
url: "https://thirdweb.com/thirdweb.png",
|
||||
url: "https://thirdweb.com/assets/og-image/thirdweb.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "thirdweb",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import { createWallet, inAppWallet } from "thirdweb/wallets";
|
||||
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
|
||||
@@ -154,27 +154,6 @@ function PageContent(props: {
|
||||
router.replace(props.redirectPath);
|
||||
}
|
||||
|
||||
if (connectionStatus === "connecting") {
|
||||
return <LoadingCard />;
|
||||
}
|
||||
|
||||
if (connectionStatus !== "connected" || screen.id === "login") {
|
||||
return <CustomConnectEmbed onLogin={onLogin} />;
|
||||
}
|
||||
|
||||
if (screen.id === "onboarding") {
|
||||
return (
|
||||
<Suspense fallback={<LoadingCard />}>
|
||||
<LazyOnboardingUI
|
||||
account={screen.account}
|
||||
onComplete={onComplete}
|
||||
redirectPath={props.redirectPath}
|
||||
redirectToCheckout={redirectToCheckout}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
const account = await getRawAccountAction();
|
||||
|
||||
@@ -193,6 +172,38 @@ function PageContent(props: {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// if suddenly disconnected
|
||||
if (connectionStatus !== "connected" && screen.id !== "login") {
|
||||
setScreen({ id: "login" });
|
||||
}
|
||||
}, [connectionStatus, screen.id]);
|
||||
|
||||
if (connectionStatus === "connecting") {
|
||||
return <LoadingCard />;
|
||||
}
|
||||
|
||||
if (connectionStatus !== "connected" || screen.id === "login") {
|
||||
return <CustomConnectEmbed onLogin={onLogin} />;
|
||||
}
|
||||
|
||||
if (screen.id === "onboarding") {
|
||||
return (
|
||||
<Suspense fallback={<LoadingCard />}>
|
||||
<LazyOnboardingUI
|
||||
account={screen.account}
|
||||
onComplete={onComplete}
|
||||
redirectPath={props.redirectPath}
|
||||
redirectToCheckout={redirectToCheckout}
|
||||
onLogout={() => {
|
||||
setScreen({ id: "login" });
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <LoadingCard />;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,10 @@ export function OnboardingChoosePlan(props: {
|
||||
billingPlan="starter"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get started for free",
|
||||
title: "Get Started for free",
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "starterPlan",
|
||||
},
|
||||
}}
|
||||
redirectPath={props.redirectPath}
|
||||
@@ -40,15 +41,14 @@ export function OnboardingChoosePlan(props: {
|
||||
billingPlan="growth"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Claim your 1-month free",
|
||||
hint: "Your free trial will end after 30 days.",
|
||||
title: "Get Started with Growth",
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "growthPlan",
|
||||
},
|
||||
variant: "default",
|
||||
}}
|
||||
canTrialGrowth={props.canTrialGrowth}
|
||||
canTrialGrowth={false}
|
||||
highlighted
|
||||
redirectPath={props.redirectPath}
|
||||
redirectToCheckout={props.redirectToCheckout}
|
||||
|
||||
@@ -14,12 +14,14 @@ type OnboardingGeneralProps = {
|
||||
account: Account;
|
||||
onSave: (email: string) => void;
|
||||
onDuplicate: (email: string) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
|
||||
account,
|
||||
onSave,
|
||||
onDuplicate,
|
||||
onLogout,
|
||||
}) => {
|
||||
const [existing, setExisting] = useState(false);
|
||||
const activeWallet = useActiveWallet();
|
||||
@@ -27,6 +29,7 @@ export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
|
||||
|
||||
async function handleLogout() {
|
||||
await doLogout();
|
||||
onLogout();
|
||||
if (activeWallet) {
|
||||
disconnect(activeWallet);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type OnboardingScreen =
|
||||
function OnboardingUI(props: {
|
||||
account: Account;
|
||||
onComplete: () => void;
|
||||
onLogout: () => void;
|
||||
// path to redirect from stripe
|
||||
redirectPath: string;
|
||||
redirectToCheckout: RedirectBillingCheckoutAction;
|
||||
@@ -68,6 +69,7 @@ function OnboardingUI(props: {
|
||||
{screen.id === "onboarding" && (
|
||||
<OnboardingGeneral
|
||||
account={account}
|
||||
onLogout={props.onLogout}
|
||||
onSave={(email) => {
|
||||
setUpdatedEmail(email);
|
||||
setScreen({
|
||||
|
||||
@@ -181,6 +181,7 @@ function SessionCard(props: {
|
||||
<Link
|
||||
className="before:absolute before:inset-0"
|
||||
href={`/chat/${props.session.id}`}
|
||||
prefetch={false}
|
||||
>
|
||||
{props.session.title || "Untitled"}
|
||||
</Link>
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { ExecuteConfig, SessionInfo } from "../api/types";
|
||||
import { newChatPageUrlStore, newSessionsStore } from "../stores";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
import { type ChatMessage, Chats } from "./Chats";
|
||||
import ContextFiltersButton from "./ContextFilters";
|
||||
import ContextFiltersButton, { ContextFiltersForm } from "./ContextFilters";
|
||||
import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent";
|
||||
|
||||
export function ChatPageContent(props: {
|
||||
@@ -362,69 +362,92 @@ export function ChatPageContent(props: {
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
|
||||
const handleUpdateContextFilters = async (
|
||||
values: ContextFilters | undefined,
|
||||
) => {
|
||||
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context filters
|
||||
if (sessionId) {
|
||||
await updateSession({
|
||||
authToken: props.authToken,
|
||||
config,
|
||||
sessionId,
|
||||
contextFilters: values,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
<WalletDisconnectedDialog
|
||||
open={showConnectModal}
|
||||
onOpenChange={setShowConnectModal}
|
||||
/>
|
||||
<header className="flex justify-start border-b bg-background p-4">
|
||||
<header className="flex justify-between border-b bg-background p-4 xl:hidden">
|
||||
<ContextFiltersButton
|
||||
contextFilters={contextFilters}
|
||||
setContextFilters={setContextFilters}
|
||||
updateContextFilters={async (values) => {
|
||||
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context filters
|
||||
if (sessionId) {
|
||||
await updateSession({
|
||||
authToken: props.authToken,
|
||||
config,
|
||||
sessionId,
|
||||
contextFilters: values,
|
||||
});
|
||||
}
|
||||
}}
|
||||
updateContextFilters={handleUpdateContextFilters}
|
||||
/>
|
||||
</header>
|
||||
<div className="relative flex grow flex-col overflow-hidden rounded-lg pb-6">
|
||||
{showEmptyState ? (
|
||||
<div className="fade-in-0 container flex max-w-[800px] grow animate-in flex-col justify-center">
|
||||
<EmptyStateChatPageContent sendMessage={handleSendMessage} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
|
||||
<Chats
|
||||
messages={messages}
|
||||
isChatStreaming={isChatStreaming}
|
||||
authToken={props.authToken}
|
||||
sessionId={sessionId}
|
||||
className="min-w-0 pt-6 pb-32"
|
||||
twAccount={props.account}
|
||||
client={client}
|
||||
enableAutoScroll={enableAutoScroll}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
/>
|
||||
|
||||
<div className="container max-w-[800px]">
|
||||
<ChatBar
|
||||
sendMessage={handleSendMessage}
|
||||
isChatStreaming={isChatStreaming}
|
||||
abortChatStream={() => {
|
||||
chatAbortController?.abort();
|
||||
setChatAbortController(undefined);
|
||||
setIsChatStreaming(false);
|
||||
// if last message is presence, remove it
|
||||
if (messages[messages.length - 1]?.type === "presence") {
|
||||
setMessages((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex grow overflow-hidden">
|
||||
<div className="relative flex grow flex-col overflow-hidden rounded-lg pb-6">
|
||||
{showEmptyState ? (
|
||||
<div className="fade-in-0 container flex max-w-[800px] grow animate-in flex-col justify-center">
|
||||
<EmptyStateChatPageContent sendMessage={handleSendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
|
||||
<Chats
|
||||
messages={messages}
|
||||
isChatStreaming={isChatStreaming}
|
||||
authToken={props.authToken}
|
||||
sessionId={sessionId}
|
||||
className="min-w-0 pt-6 pb-32"
|
||||
twAccount={props.account}
|
||||
client={client}
|
||||
enableAutoScroll={enableAutoScroll}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
|
||||
Nebula may make mistakes. Please use with discretion
|
||||
</p>
|
||||
<div className="container max-w-[800px]">
|
||||
<ChatBar
|
||||
sendMessage={handleSendMessage}
|
||||
isChatStreaming={isChatStreaming}
|
||||
abortChatStream={() => {
|
||||
chatAbortController?.abort();
|
||||
setChatAbortController(undefined);
|
||||
setIsChatStreaming(false);
|
||||
// if last message is presence, remove it
|
||||
if (messages[messages.length - 1]?.type === "presence") {
|
||||
setMessages((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
|
||||
Nebula may make mistakes. Please use with discretion
|
||||
</p>
|
||||
</div>
|
||||
<aside className="hidden w-[360px] flex-col border-l bg-card pt-4 xl:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="font-semibold text-lg tracking-tight">Context</h3>
|
||||
<p className="mb-5 text-muted-foreground text-sm">
|
||||
Provide context to Nebula for your prompts
|
||||
</p>
|
||||
</div>
|
||||
<ContextFiltersForm
|
||||
contextFilters={contextFilters}
|
||||
setContextFilters={setContextFilters}
|
||||
modal={undefined}
|
||||
updateContextFilters={handleUpdateContextFilters}
|
||||
formBodyClassName="px-4"
|
||||
formActionContainerClassName="px-4 border-t-0 pt-0 bg-transparent"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
FileCode2Icon,
|
||||
MessageSquareShareIcon,
|
||||
MessagesSquareIcon,
|
||||
SquareDashedBottomCodeIcon,
|
||||
TextIcon,
|
||||
@@ -29,8 +31,11 @@ export function ChatSidebar(props: {
|
||||
return (
|
||||
<div className="flex h-full flex-col p-2">
|
||||
<div className="flex items-center justify-start gap-3 p-2 lg:justify-between">
|
||||
<Link href="/">
|
||||
<NebulaIcon className="size-8 text-foreground" />
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<NebulaIcon className="size-8 text-foreground" aria-label="Nebula" />
|
||||
<span className="font-semibold text-lg tracking-tight">
|
||||
Playground
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Badge variant="secondary" className="gap-1 py-1">
|
||||
@@ -48,6 +53,13 @@ export function ChatSidebar(props: {
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/nebula/api-reference"
|
||||
icon={FileCode2Icon}
|
||||
label="API Reference"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SidebarIconLink
|
||||
href="/chat/history"
|
||||
icon={MessagesSquareIcon}
|
||||
@@ -79,7 +91,14 @@ export function ChatSidebar(props: {
|
||||
</ScrollShadow>
|
||||
)}
|
||||
|
||||
<div className="mb-3 border-b border-dashed pt-2 pb-3">
|
||||
<div className="mb-3 border-y border-dashed py-3">
|
||||
<SidebarIconLink
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLSeM3fJRyywihRZUF1fiTNKEpJ_AzAcohRwXPpLr_3zxQ6W-tg/viewform?usp=sharing"
|
||||
icon={MessageSquareShareIcon}
|
||||
label="Take our quick survey!"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/changelog"
|
||||
icon={TextIcon}
|
||||
@@ -116,6 +135,7 @@ function SidebarIconLink(props: {
|
||||
href={props.href}
|
||||
target={props.target}
|
||||
className="!justify-start !px-3 w-full gap-2.5 rounded-lg text-left"
|
||||
prefetch={false}
|
||||
>
|
||||
<props.icon className="size-4" />
|
||||
{props.label}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -15,15 +16,14 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { AutoResizeTextarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { SlidersHorizontalIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -39,9 +39,6 @@ export default function ContextFiltersButton(props: {
|
||||
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.updateContextFilters,
|
||||
});
|
||||
|
||||
const chainIds = props.contextFilters?.chainIds;
|
||||
const contractAddresses = props.contextFilters?.contractAddresses;
|
||||
@@ -85,21 +82,22 @@ export default function ContextFiltersButton(props: {
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-0">
|
||||
<ContextFilterDialogContent
|
||||
contextFilters={props.contextFilters}
|
||||
isPending={updateMutation.isPending}
|
||||
updateFilters={async (data) => {
|
||||
const promise = updateMutation.mutateAsync(data);
|
||||
toast.promise(promise, {
|
||||
success: "Context filters updated",
|
||||
error: "Failed to update context filters",
|
||||
});
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-1">
|
||||
<DialogTitle className="font-semibold text-xl">
|
||||
Context Filters
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Provide context to Nebula for your prompts
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
promise.then(() => {
|
||||
props.setContextFilters(data);
|
||||
setIsOpen(false);
|
||||
});
|
||||
<ContextFiltersForm
|
||||
contextFilters={props.contextFilters}
|
||||
updateContextFilters={props.updateContextFilters}
|
||||
setContextFilters={props.setContextFilters}
|
||||
modal={{
|
||||
close: () => setIsOpen(false),
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -138,11 +136,22 @@ const formSchema = z.object({
|
||||
walletAddresses: commaSeparateListOfAddresses,
|
||||
});
|
||||
|
||||
function ContextFilterDialogContent(props: {
|
||||
export function ContextFiltersForm(props: {
|
||||
contextFilters: ContextFilters | undefined;
|
||||
updateFilters: (filters: ContextFilters | undefined) => void;
|
||||
isPending: boolean;
|
||||
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
|
||||
setContextFilters: (filters: ContextFilters | undefined) => void;
|
||||
formBodyClassName?: string;
|
||||
formActionContainerClassName?: string;
|
||||
modal:
|
||||
| {
|
||||
close: () => void;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.updateContextFilters,
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
@@ -172,26 +181,37 @@ function ContextFilterDialogContent(props: {
|
||||
.split(",")
|
||||
.filter((v) => v.trim());
|
||||
|
||||
props.updateFilters({
|
||||
const data = {
|
||||
chainIds: chainIdsArray,
|
||||
contractAddresses: contractAddressesArray,
|
||||
walletAddresses: walletAddressesArray,
|
||||
};
|
||||
|
||||
const promise = updateMutation.mutateAsync(data);
|
||||
|
||||
toast.promise(promise, {
|
||||
success: "Context filters updated",
|
||||
error: "Failed to update context filters",
|
||||
});
|
||||
|
||||
promise.then(() => {
|
||||
props.setContextFilters(data);
|
||||
props.modal?.close?.();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<DialogHeader className="px-6 pt-6 pb-1">
|
||||
<DialogTitle className="font-semibold text-xl">
|
||||
Context Filters
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Provide context information to Nebula for your prompts
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-5 px-6">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn("flex w-full grow flex-col")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5 px-6 pb-6",
|
||||
props.formBodyClassName,
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chainIds"
|
||||
@@ -200,6 +220,8 @@ function ContextFilterDialogContent(props: {
|
||||
<FormLabel>Chain IDs</FormLabel>
|
||||
<FormControl>
|
||||
<MultiNetworkSelector
|
||||
disableChainId
|
||||
className="bg-background"
|
||||
selectedChainIds={form
|
||||
.watch()
|
||||
.chainIds.split(",")
|
||||
@@ -226,12 +248,9 @@ function ContextFilterDialogContent(props: {
|
||||
<AutoResizeTextarea
|
||||
{...field}
|
||||
placeholder="0x123..., 0x456..."
|
||||
className="min-h-[32px] resize-none"
|
||||
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma separated list of contract addresses
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -247,25 +266,36 @@ function ContextFilterDialogContent(props: {
|
||||
<AutoResizeTextarea
|
||||
{...field}
|
||||
placeholder="0x123..., 0x456..."
|
||||
className="min-h-[32px] resize-none"
|
||||
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma separated list of wallet addresses
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex justify-end gap-3 border-t bg-card p-6">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button className="min-w-28 gap-2" type="submit">
|
||||
{props.isPending && <Spinner className="size-4" />}
|
||||
Update
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-end gap-3 border-t bg-card p-6",
|
||||
props.formActionContainerClassName,
|
||||
)}
|
||||
>
|
||||
{props.modal && (
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
)}
|
||||
<Button
|
||||
className="min-w-24 gap-2"
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending && <Spinner className="size-4" />}
|
||||
Update Context
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="py-10 lg:py-16">
|
||||
<div className="relative py-10">
|
||||
<FancyBorders />
|
||||
<div className="flex justify-center">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import type { Account as TWAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function TotalSponsoredChartCardUI({
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
isCurrency
|
||||
title={title || "Total Sponsored"}
|
||||
title={title || "Gas Sponsored"}
|
||||
chartConfig={chartConfig}
|
||||
data={timeSeriesData}
|
||||
activeChart={
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import type { TransactionStats } from "types/analytics";
|
||||
import { EmptyAccountAbstractionChartContent } from "../../../../../components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
|
||||
import { BarChart } from "../../../components/Analytics/BarChart";
|
||||
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
|
||||
|
||||
export async function TransactionsChartCardUI({
|
||||
data,
|
||||
aggregatedData,
|
||||
searchParams,
|
||||
className,
|
||||
onlyMainnet,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
data: TransactionStats[];
|
||||
aggregatedData: TransactionStats[];
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
className?: string;
|
||||
onlyMainnet?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const uniqueChainIds = [
|
||||
...new Set(data.map((item) => item.chainId).filter(Boolean)),
|
||||
];
|
||||
const chains = await Promise.all(
|
||||
uniqueChainIds.map((chainId) =>
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(Number(chainId))).catch(() => undefined),
|
||||
),
|
||||
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
|
||||
|
||||
// Process data to combine by date and chain type
|
||||
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
|
||||
for (const item of data) {
|
||||
const chain = chains.find((c) => c.chainId === Number(item.chainId));
|
||||
|
||||
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
|
||||
if (chain?.testnet) {
|
||||
existing.testnet += item.count;
|
||||
} else {
|
||||
existing.mainnet += item.count;
|
||||
}
|
||||
dateMap.set(item.date, existing);
|
||||
}
|
||||
|
||||
// Convert to array and sort by date
|
||||
const timeSeriesData = Array.from(dateMap.entries())
|
||||
.map(([date, values]) => ({
|
||||
date,
|
||||
mainnet: values.mainnet,
|
||||
testnet: values.testnet,
|
||||
total: values.mainnet + values.testnet,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const processedAggregatedData = {
|
||||
mainnet: aggregatedData
|
||||
.filter(
|
||||
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.count, 0),
|
||||
testnet: aggregatedData
|
||||
.filter(
|
||||
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.count, 0),
|
||||
total: aggregatedData.reduce((acc, curr) => acc + curr.count, 0),
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
mainnet: {
|
||||
label: "Mainnet Chains",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
testnet: {
|
||||
label: "Testnet Chains",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
total: {
|
||||
label: "All Chains",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
};
|
||||
|
||||
if (onlyMainnet) {
|
||||
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
|
||||
return (
|
||||
<div className={cn("rounded-lg border p-4 lg:p-6", className)}>
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
{title || "Transactions"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground"> {description}</p>
|
||||
<BarChart
|
||||
chartConfig={chartConfig}
|
||||
data={filteredData}
|
||||
activeKey="mainnet"
|
||||
emptyChartContent={<EmptyAccountAbstractionChartContent />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
title={title || "Transactions"}
|
||||
chartConfig={chartConfig}
|
||||
data={timeSeriesData}
|
||||
activeChart={
|
||||
(searchParams?.client_transactions as keyof typeof chartConfig) ??
|
||||
"mainnet"
|
||||
}
|
||||
queryKey="client_transactions"
|
||||
existingQueryParams={searchParams}
|
||||
aggregateFn={(_data, key) => processedAggregatedData[key]}
|
||||
className={className}
|
||||
// Get the trend from the last two COMPLETE periods
|
||||
trendFn={(data, key) =>
|
||||
data.filter((d) => (d[key] as number) > 0).length >= 3
|
||||
? ((data[data.length - 2]?.[key] as number) ?? 0) /
|
||||
((data[data.length - 3]?.[key] as number) ?? 0) -
|
||||
1
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getWalletConnections } from "@/api/analytics";
|
||||
import { type Project, getProjects } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { Changelog } from "components/dashboard/Changelog";
|
||||
import { subDays } from "date-fns";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TeamProjectsPage } from "./~/projects/TeamProjectsPage";
|
||||
import {
|
||||
type ProjectWithAnalytics,
|
||||
TeamProjectsPage,
|
||||
} from "./~/projects/TeamProjectsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
@@ -15,12 +20,13 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
const projects = await getProjects(params.team_slug);
|
||||
const projectsWithTotalWallets = await getProjectsWithAnalytics(projects);
|
||||
|
||||
return (
|
||||
<div className="container flex grow flex-col gap-12 py-8 lg:flex-row">
|
||||
<div className="flex grow flex-col">
|
||||
<h1 className="mb-4 font-semibold text-2xl tracking-tight">Projects</h1>
|
||||
<TeamProjectsPage projects={projects} team={team} />
|
||||
<TeamProjectsPage projects={projectsWithTotalWallets} team={team} />
|
||||
</div>
|
||||
<div className="shrink-0 lg:w-[320px]">
|
||||
<h2 className="mb-4 font-semibold text-2xl tracking-tight">
|
||||
@@ -31,3 +37,38 @@ export default async function Page(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function getProjectsWithAnalytics(
|
||||
projects: Project[],
|
||||
): Promise<Array<ProjectWithAnalytics>> {
|
||||
return Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = subDays(today, 30);
|
||||
|
||||
const data = await getWalletConnections({
|
||||
clientId: p.publishableKey,
|
||||
period: "all",
|
||||
from: thirtyDaysAgo,
|
||||
to: today,
|
||||
});
|
||||
|
||||
let uniqueWalletsConnected = 0;
|
||||
for (const d of data) {
|
||||
uniqueWalletsConnected += d.uniqueWalletsConnected;
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
monthlyActiveUsers: uniqueWalletsConnected,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...p,
|
||||
monthlyActiveUsers: 0,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
getClientTransactions,
|
||||
getInAppWalletUsage,
|
||||
getUserOpUsage,
|
||||
getWalletConnections,
|
||||
@@ -31,9 +32,10 @@ import { getValidAccount } from "app/account/settings/getAccount";
|
||||
import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard";
|
||||
import { Suspense } from "react";
|
||||
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
|
||||
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
|
||||
|
||||
// revalidate every 5 minutes
|
||||
export const revalidate = 300;
|
||||
export const maxDuration = 300;
|
||||
|
||||
type SearchParams = {
|
||||
usersChart?: string;
|
||||
@@ -100,6 +102,8 @@ async function OverviewPageContent(props: {
|
||||
inAppWalletUsage,
|
||||
userOpUsageTimeSeries,
|
||||
userOpUsage,
|
||||
clientTransactionsTimeSeries,
|
||||
clientTransactions,
|
||||
] = await Promise.all([
|
||||
// Aggregated wallet connections
|
||||
getWalletConnections({
|
||||
@@ -135,6 +139,19 @@ async function OverviewPageContent(props: {
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
// Client transactions
|
||||
getClientTransactions({
|
||||
accountId: account.id,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}),
|
||||
getClientTransactions({
|
||||
accountId: account.id,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
]);
|
||||
|
||||
const isEmpty =
|
||||
@@ -180,6 +197,14 @@ async function OverviewPageContent(props: {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{clientTransactions.length > 0 && (
|
||||
<TransactionsChartCardUI
|
||||
searchParams={searchParams}
|
||||
data={clientTransactionsTimeSeries}
|
||||
aggregatedData={clientTransactions}
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
/>
|
||||
)}
|
||||
{userOpUsage.length > 0 ? (
|
||||
<TotalSponsoredChartCardUI
|
||||
searchParams={searchParams}
|
||||
@@ -189,7 +214,7 @@ async function OverviewPageContent(props: {
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
metric="Sponsored Transactions"
|
||||
metric="Gas Sponsored"
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
/>
|
||||
)}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import GenericLoadingPage from "../../../../ecosystem/loading";
|
||||
import { getEngineAccessPermission } from "../../../_utils/getEngineAccessPermission";
|
||||
import { EngineErrorPage } from "./EngineErrorPage";
|
||||
|
||||
export function EnsureEnginePermission(props: {
|
||||
engineId: string;
|
||||
accountId: string;
|
||||
authToken: string;
|
||||
children: React.ReactNode;
|
||||
teamSlug: string;
|
||||
instance: EngineInstance;
|
||||
}) {
|
||||
const { instance } = props;
|
||||
const rootPath = `/team/${props.teamSlug}/~/engine`;
|
||||
|
||||
const permissionQuery = useQuery({
|
||||
queryKey: [
|
||||
"engine-permission",
|
||||
{
|
||||
instanceUrl: instance.url,
|
||||
authToken: props.authToken,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
const url = instance.url;
|
||||
if (!url) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return getEngineAccessPermission({
|
||||
authToken: props.authToken,
|
||||
instanceUrl: url,
|
||||
});
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (permissionQuery.isPending) {
|
||||
return <GenericLoadingPage />;
|
||||
}
|
||||
|
||||
if (!permissionQuery.data) {
|
||||
return (
|
||||
<EngineErrorPage rootPath={rootPath}>
|
||||
Failed to verify access to engine instance
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
|
||||
const permission = permissionQuery.data;
|
||||
|
||||
if (!permission.ok) {
|
||||
if (permission.status === 404) {
|
||||
return (
|
||||
<EngineErrorPage rootPath={rootPath}>
|
||||
<p> Engine instance not found </p>
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (permission.fetchFailed) {
|
||||
return (
|
||||
<EngineErrorPage rootPath={rootPath}>
|
||||
<p> Engine instance could not be reached </p>
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (permission.status === 401 || permission.status === 500) {
|
||||
return (
|
||||
<EngineErrorPage rootPath={rootPath}>
|
||||
<div>
|
||||
<p>You are not an admin for this Engine instance. </p>
|
||||
<p> Contact the owner to add your wallet as an admin</p>
|
||||
</div>
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div>{props.children}</div>;
|
||||
}
|
||||
+15
-57
@@ -7,10 +7,10 @@ import Link from "next/link";
|
||||
import { getValidAccount } from "../../../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../../login/loginRedirect";
|
||||
import { getEngineAccessPermission } from "../../_utils/getEngineAccessPermission";
|
||||
import { getEngineInstance } from "../../_utils/getEngineInstance";
|
||||
import { EngineErrorPage } from "./_components/EngineErrorPage";
|
||||
import { EngineSidebarLayout } from "./_components/EnginePageLayout";
|
||||
import { EnsureEnginePermission } from "./_components/EnsureEnginePermission";
|
||||
import { EngineVersionBadge } from "./_components/version";
|
||||
|
||||
export default async function Layout(props: {
|
||||
@@ -49,70 +49,28 @@ export default async function Layout(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const permission = await getEngineAccessPermission({
|
||||
authToken,
|
||||
instanceUrl: instance.url,
|
||||
});
|
||||
|
||||
return (
|
||||
<EngineSidebarLayout engineId={params.engineId} teamSlug={params.team_slug}>
|
||||
<EngineInstanceLayoutContent
|
||||
<EnsureEnginePermission
|
||||
teamSlug={params.team_slug}
|
||||
accountId={account.id}
|
||||
authToken={authToken}
|
||||
engineId={params.engineId}
|
||||
instance={instance}
|
||||
permission={permission}
|
||||
rootPath={engineRootLayoutPath}
|
||||
team_slug={params.team_slug}
|
||||
>
|
||||
{props.children}
|
||||
</EngineInstanceLayoutContent>
|
||||
<div>
|
||||
<EngineInstanceHeader
|
||||
instance={instance}
|
||||
rootPath={engineRootLayoutPath}
|
||||
teamSlug={params.team_slug}
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
</EnsureEnginePermission>
|
||||
</EngineSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineInstanceLayoutContent(props: {
|
||||
permission: {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
};
|
||||
instance: EngineInstance;
|
||||
rootPath: string;
|
||||
children: React.ReactNode;
|
||||
team_slug: string;
|
||||
}) {
|
||||
const { instance, permission, rootPath, team_slug } = props;
|
||||
|
||||
if (!permission.ok) {
|
||||
if (permission.status === 404) {
|
||||
return (
|
||||
<EngineErrorPage rootPath={rootPath}>
|
||||
<p> Engine Instance Not Found </p>
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (permission.status === 401 || permission.status === 500) {
|
||||
return (
|
||||
<EngineErrorPage rootPath={rootPath}>
|
||||
<div>
|
||||
<p>You are not an admin for this Engine instance. </p>
|
||||
<p> Contact the owner to add your wallet as an admin</p>
|
||||
</div>
|
||||
</EngineErrorPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EngineInstanceHeader
|
||||
instance={instance}
|
||||
rootPath={rootPath}
|
||||
teamSlug={team_slug}
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineInstanceHeader(props: {
|
||||
instance: EngineInstance;
|
||||
rootPath: string;
|
||||
|
||||
+24
-15
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { FormItem } from "@/components/ui/form";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
type BackendWallet,
|
||||
useEngineBackendWalletBalance,
|
||||
@@ -34,8 +35,9 @@ import { type ColumnDef, createColumnHelper } from "@tanstack/react-table";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { TWTable } from "components/shared/TWTable";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { EngineBackendWalletOptions } from "lib/engine";
|
||||
import { useActiveChainAsDashboardChain } from "lib/v5-adapter";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import {
|
||||
DownloadIcon,
|
||||
PencilIcon,
|
||||
@@ -49,7 +51,6 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { getAddress } from "thirdweb";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import invariant from "tiny-invariant";
|
||||
import { FormHelperText, FormLabel, LinkButton, Text } from "tw-components";
|
||||
import { prettyPrintCurrency } from "./utils";
|
||||
|
||||
@@ -59,6 +60,7 @@ interface BackendWalletsTableProps {
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
authToken: string;
|
||||
chainId: number;
|
||||
}
|
||||
|
||||
interface BackendWalletDashboard extends BackendWallet {
|
||||
@@ -72,35 +74,37 @@ interface BackendWalletBalanceCellProps {
|
||||
instanceUrl: string;
|
||||
address: string;
|
||||
authToken: string;
|
||||
chainId: number;
|
||||
}
|
||||
|
||||
const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
|
||||
instanceUrl,
|
||||
address,
|
||||
authToken,
|
||||
chainId,
|
||||
}) => {
|
||||
const { data: backendWalletBalance } = useEngineBackendWalletBalance({
|
||||
instanceUrl: instanceUrl,
|
||||
address,
|
||||
authToken,
|
||||
chainId,
|
||||
});
|
||||
const chain = useActiveChainAsDashboardChain();
|
||||
if (!chain || !backendWalletBalance) {
|
||||
return;
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
if (!backendWalletBalance) {
|
||||
return <Skeleton className="h-5 w-32 rounded-lg" />;
|
||||
}
|
||||
|
||||
const balanceDisplay = prettyPrintCurrency({
|
||||
amount: backendWalletBalance.displayValue,
|
||||
symbol: backendWalletBalance.symbol,
|
||||
symbol: backendWalletBalance.symbol || chain.nativeCurrency?.symbol,
|
||||
});
|
||||
|
||||
const balanceComponent = (
|
||||
<Text fontWeight={backendWalletBalance.value === "0" ? "light" : "bold"}>
|
||||
{balanceDisplay}
|
||||
</Text>
|
||||
<div className="text-muted-foreground">{balanceDisplay}</div>
|
||||
);
|
||||
|
||||
const explorer = chain?.explorers?.[0];
|
||||
const explorer = chain.blockExplorers?.[0];
|
||||
if (!explorer) {
|
||||
return balanceComponent;
|
||||
}
|
||||
@@ -123,6 +127,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
isPending,
|
||||
isFetched,
|
||||
authToken,
|
||||
chainId,
|
||||
}) => {
|
||||
const editDisclosure = useDisclosure();
|
||||
const receiveDisclosure = useDisclosure();
|
||||
@@ -163,13 +168,14 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
instanceUrl={instanceUrl}
|
||||
address={address}
|
||||
authToken={authToken}
|
||||
chainId={chainId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
id: "balance",
|
||||
}),
|
||||
];
|
||||
}, [instanceUrl, authToken]);
|
||||
}, [instanceUrl, authToken, chainId]);
|
||||
|
||||
const [selectedBackendWallet, setSelectedBackendWallet] =
|
||||
useState<BackendWallet>();
|
||||
@@ -242,6 +248,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
|
||||
disclosure={sendDisclosure}
|
||||
instanceUrl={instanceUrl}
|
||||
authToken={authToken}
|
||||
chainId={chainId}
|
||||
/>
|
||||
)}
|
||||
{selectedBackendWallet && deleteDisclosure.isOpen && (
|
||||
@@ -417,24 +424,28 @@ const SendFundsModal = ({
|
||||
disclosure,
|
||||
instanceUrl,
|
||||
authToken,
|
||||
chainId,
|
||||
}: {
|
||||
fromWallet: BackendWallet;
|
||||
backendWallets: BackendWallet[];
|
||||
disclosure: UseDisclosureReturn;
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
chainId: number;
|
||||
}) => {
|
||||
const chain = useActiveChainAsDashboardChain();
|
||||
const form = useForm<SendFundsInput>();
|
||||
const sendTokens = useEngineSendTokens({
|
||||
instanceUrl,
|
||||
authToken,
|
||||
});
|
||||
const { idToChain } = useAllChainsData();
|
||||
const { data: backendWalletBalance } = useEngineBackendWalletBalance({
|
||||
instanceUrl,
|
||||
address: fromWallet.address,
|
||||
authToken,
|
||||
chainId: chainId,
|
||||
});
|
||||
const chain = idToChain.get(chainId);
|
||||
const toWalletDisclosure = useDisclosure();
|
||||
|
||||
if (!backendWalletBalance) {
|
||||
@@ -442,11 +453,9 @@ const SendFundsModal = ({
|
||||
}
|
||||
|
||||
const onSubmit = async (data: SendFundsInput) => {
|
||||
invariant(chain, "chain is required");
|
||||
|
||||
const promise = sendTokens.mutateAsync(
|
||||
{
|
||||
chainId: chain.chainId,
|
||||
chainId: chainId,
|
||||
fromAddress: fromWallet.address,
|
||||
toAddress: data.toAddress,
|
||||
amount: data.amount,
|
||||
|
||||
+15
-3
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import {
|
||||
type EngineInstance,
|
||||
useEngineBackendWallets,
|
||||
useEngineWalletConfig,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useActiveWalletChain } from "thirdweb/react";
|
||||
import { BackendWalletsTable } from "./backend-wallets-table";
|
||||
import { CreateBackendWalletButton } from "./create-backend-wallet-button";
|
||||
import { ImportBackendWalletButton } from "./import-backend-wallet-button";
|
||||
@@ -43,6 +45,10 @@ function BackendWalletsSection(props: {
|
||||
authToken: string;
|
||||
}) {
|
||||
const { instance, teamSlug, authToken } = props;
|
||||
const activeWalletChain = useActiveWalletChain();
|
||||
const [_chainId, setChainId] = useState<number>();
|
||||
const chainId = _chainId || activeWalletChain?.id || 1;
|
||||
|
||||
const backendWallets = useEngineBackendWallets({
|
||||
instanceUrl: instance.url,
|
||||
authToken,
|
||||
@@ -105,9 +111,14 @@ function BackendWalletsSection(props: {
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Show balance for</span>
|
||||
{/* TODO - Replace with simple network selector - there's no need for user to switch chain */}
|
||||
<div className="flex flex-row">
|
||||
<NetworkSelectorButton />
|
||||
<SingleNetworkSelector
|
||||
chainId={chainId}
|
||||
onChange={setChainId}
|
||||
className="min-w-40 max-w-52 lg:max-w-60"
|
||||
popoverContentClassName="!w-[80vw] md:!w-[500px]"
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,6 +131,7 @@ function BackendWalletsSection(props: {
|
||||
isPending={backendWallets.isPending}
|
||||
isFetched={backendWallets.isFetched}
|
||||
authToken={authToken}
|
||||
chainId={chainId}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
+2
-2
@@ -3,9 +3,9 @@ export const prettyPrintCurrency = ({
|
||||
symbol,
|
||||
}: {
|
||||
amount?: string | number;
|
||||
symbol: string;
|
||||
symbol?: string;
|
||||
}): string => {
|
||||
const amountNumber =
|
||||
typeof amount === "string" ? Number.parseFloat(amount) : (amount ?? 0);
|
||||
return `${amountNumber.toFixed(6)} ${symbol}`;
|
||||
return `${amountNumber.toFixed(6)} ${symbol || ""}`;
|
||||
};
|
||||
|
||||
+27
-11
@@ -1,17 +1,33 @@
|
||||
"use client";
|
||||
|
||||
// can not fetch engine permission on server - instance url can be localhost url
|
||||
|
||||
export async function getEngineAccessPermission(params: {
|
||||
authToken: string;
|
||||
instanceUrl: string;
|
||||
}) {
|
||||
const res = await fetch(`${params.instanceUrl}auth/permissions/get-all`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const instanceUrl = params.instanceUrl.endsWith("/")
|
||||
? params.instanceUrl
|
||||
: `${params.instanceUrl}/`;
|
||||
|
||||
return {
|
||||
ok: res.ok, // has access if this is true
|
||||
status: res.status,
|
||||
};
|
||||
const res = await fetch(`${instanceUrl}auth/permissions/get-all`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: res.ok, // has access if this is true
|
||||
status: res.status,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
fetchFailed: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,5 +30,5 @@ export async function engineInstancePageHandler(params: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return { instance, authToken };
|
||||
return { instance, authToken, account };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
|
||||
import { CopyButton } from "@/components/ui/CopyButton";
|
||||
import { PaginationButtons } from "@/components/pagination-buttons";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,6 +12,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,51 +25,90 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { ChevronDownIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type SortById = "name" | "createdAt";
|
||||
type SortById = "name" | "createdAt" | "monthlyActiveUsers";
|
||||
|
||||
export type ProjectWithAnalytics = Project & {
|
||||
monthlyActiveUsers: number;
|
||||
};
|
||||
|
||||
export function TeamProjectsPage(props: {
|
||||
projects: Project[];
|
||||
projects: ProjectWithAnalytics[];
|
||||
team: Team;
|
||||
}) {
|
||||
const { projects } = props;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortBy, setSortBy] = useState<SortById>("createdAt");
|
||||
const [sortBy, setSortBy] = useState<SortById>("monthlyActiveUsers");
|
||||
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
|
||||
useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
let projectsToShow = !searchTerm
|
||||
? projects
|
||||
: projects.filter(
|
||||
(project) =>
|
||||
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.publishableKey
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
const sortedProjects = useMemo(() => {
|
||||
let _projectsToShow = !searchTerm
|
||||
? projects
|
||||
: projects.filter(
|
||||
(project) =>
|
||||
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.publishableKey
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
if (sortBy === "name") {
|
||||
projectsToShow = projectsToShow.sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
} else if (sortBy === "createdAt") {
|
||||
projectsToShow = projectsToShow.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
if (sortBy === "name") {
|
||||
_projectsToShow = _projectsToShow.sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
} else if (sortBy === "createdAt") {
|
||||
_projectsToShow = _projectsToShow.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
} else if (sortBy === "monthlyActiveUsers") {
|
||||
_projectsToShow = _projectsToShow.sort(
|
||||
(a, b) => b.monthlyActiveUsers - a.monthlyActiveUsers,
|
||||
);
|
||||
}
|
||||
|
||||
return _projectsToShow;
|
||||
}, [searchTerm, sortBy, projects]);
|
||||
|
||||
const pageSize = 8;
|
||||
const [page, setPage] = useState(1);
|
||||
const paginatedProjects = sortedProjects.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize,
|
||||
);
|
||||
|
||||
const showPagination = sortedProjects.length > pageSize;
|
||||
const totalPages = Math.ceil(sortedProjects.length / pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
{/* Filters + Add New */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<SearchInput value={searchTerm} onValueChange={setSearchTerm} />
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onValueChange={(v) => {
|
||||
setSearchTerm(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<SelectBy value={sortBy} onChange={setSortBy} />
|
||||
<SelectBy
|
||||
value={sortBy}
|
||||
onChange={(v) => {
|
||||
setSortBy(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<AddNewButton
|
||||
createProject={() => setIsCreateProjectDialogOpen(true)}
|
||||
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
|
||||
@@ -74,23 +119,33 @@ export function TeamProjectsPage(props: {
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Projects */}
|
||||
{projectsToShow.length === 0 ? (
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="mb-5 text-center">No projects created</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setIsCreateProjectDialogOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Create a Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{paginatedProjects.length === 0 ? (
|
||||
<>
|
||||
{searchTerm !== "" ? (
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="mb-5 text-center">No projects found</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="mb-5 text-center">No projects created</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setIsCreateProjectDialogOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Create a Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
{projectsToShow.map((project) => {
|
||||
{paginatedProjects.map((project) => {
|
||||
return (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
@@ -102,9 +157,20 @@ export function TeamProjectsPage(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LazyCreateAPIKeyDialog
|
||||
open={isCreateProjectDialogOpen}
|
||||
onOpenChange={setIsCreateProjectDialogOpen}
|
||||
teamSlug={props.team.slug}
|
||||
onCreateAndComplete={() => {
|
||||
// refresh projects
|
||||
router.refresh();
|
||||
@@ -118,7 +184,7 @@ export function TeamProjectsPage(props: {
|
||||
}
|
||||
|
||||
function ProjectCard(props: {
|
||||
project: Project;
|
||||
project: ProjectWithAnalytics;
|
||||
team_slug: string;
|
||||
}) {
|
||||
const { project, team_slug } = props;
|
||||
@@ -130,34 +196,51 @@ function ProjectCard(props: {
|
||||
{/* TODO - set image */}
|
||||
<ProjectAvatar className="size-10 rounded-full" src="" />
|
||||
|
||||
<div className="flex-grow flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link
|
||||
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
|
||||
// remove /connect when we have overview page
|
||||
href={`/team/${team_slug}/${project.slug}`}
|
||||
>
|
||||
<h2 className="font-medium text-base">{project.name}</h2>
|
||||
</Link>
|
||||
<CopyButton
|
||||
text={project.publishableKey}
|
||||
iconClassName="z-10 size-3"
|
||||
className="!h-auto !w-auto -translate-x-1 p-2 hover:bg-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col gap-1">
|
||||
<Link
|
||||
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
|
||||
// remove /connect when we have overview page
|
||||
href={`/team/${team_slug}/${project.slug}`}
|
||||
>
|
||||
<h2 className="font-medium text-base">{project.name}</h2>
|
||||
</Link>
|
||||
|
||||
<p className="flex items-center text-muted-foreground text-sm">
|
||||
{truncate(project.publishableKey, 32)}
|
||||
<p className="flex items-center gap-1 text-muted-foreground text-sm">
|
||||
<span>{project.monthlyActiveUsers}</span>
|
||||
Monthly Active Users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="z-10 h-auto w-auto p-2" variant="ghost">
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-1">
|
||||
<CopyTextButton
|
||||
textToCopy={project.publishableKey}
|
||||
textToShow="Copy Client ID"
|
||||
copyIconPosition="right"
|
||||
tooltip={undefined}
|
||||
variant="ghost"
|
||||
className="flex h-10 w-full justify-between gap-3 rounded-md px-4 py-2"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/team/${team_slug}/${project.slug}/settings`}>
|
||||
Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(str: string, stringLimit: number) {
|
||||
return str.length > stringLimit ? `${str.slice(0, stringLimit)}...` : str;
|
||||
}
|
||||
|
||||
function SearchInput(props: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
@@ -209,10 +292,11 @@ function SelectBy(props: {
|
||||
value: SortById;
|
||||
onChange: (value: SortById) => void;
|
||||
}) {
|
||||
const values: SortById[] = ["name", "createdAt"];
|
||||
const values: SortById[] = ["name", "createdAt", "monthlyActiveUsers"];
|
||||
const valueToLabel: Record<SortById, string> = {
|
||||
name: "Name",
|
||||
createdAt: "Creation Date",
|
||||
monthlyActiveUsers: "Monthly Active Users",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -223,7 +307,12 @@ function SelectBy(props: {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-[200px] bg-card capitalize">
|
||||
Sort by {valueToLabel[props.value]}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="!hidden lg:!inline text-muted-foreground">
|
||||
Sort by
|
||||
</span>
|
||||
{valueToLabel[props.value]}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{values.map((value) => (
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { defineChain, getContract } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { TransactionsChartCardUI } from "../../../(team)/_components/TransactionsCard";
|
||||
import { getThirdwebClient } from "../../../../../../@/constants/thirdweb.server";
|
||||
import { fetchDashboardContractMetadata } from "../../../../../../@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
|
||||
import type { TransactionStats } from "../../../../../../types/analytics";
|
||||
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
|
||||
|
||||
export function TransactionsChartsUI({
|
||||
data,
|
||||
aggregatedData,
|
||||
searchParams,
|
||||
}: {
|
||||
data: TransactionStats[];
|
||||
aggregatedData: TransactionStats[];
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TransactionsChartCardUI
|
||||
searchParams={searchParams}
|
||||
data={data}
|
||||
aggregatedData={aggregatedData}
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
/>
|
||||
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
|
||||
<ChainDistributionCard data={aggregatedData} />
|
||||
<ContractDistributionCard data={aggregatedData} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function ChainDistributionCard({ data }: { data: TransactionStats[] }) {
|
||||
const reducedData = await Promise.all(
|
||||
Object.entries(
|
||||
data.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.chainId] = (acc[curr.chainId] || 0) + curr.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10) // only top ten
|
||||
.map(async ([key, value]) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(key));
|
||||
const chainMeta = await getChainMetadata(chain).catch(() => undefined);
|
||||
return {
|
||||
label: chainMeta?.slug || chain.id.toString(),
|
||||
value,
|
||||
link: `/${chain.id}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const aggregateFn = () => new Set(data.map((d) => `${d.chainId}`)).size;
|
||||
|
||||
return (
|
||||
<PieChartCard title="Chains" data={reducedData} aggregateFn={aggregateFn} />
|
||||
);
|
||||
}
|
||||
|
||||
async function ContractDistributionCard({
|
||||
data,
|
||||
}: { data: TransactionStats[] }) {
|
||||
const reducedData = await Promise.all(
|
||||
Object.entries(
|
||||
data
|
||||
.filter((d) => d.contractAddress)
|
||||
.reduce(
|
||||
(acc, curr) => {
|
||||
acc[`${curr.chainId}:${curr.contractAddress}`] =
|
||||
(acc[`${curr.chainId}:${curr.contractAddress}`] || 0) +
|
||||
curr.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10) // only top ten
|
||||
.map(async ([key, value]) => {
|
||||
const [chainId, contractAddress] = key.split(":");
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(chainId));
|
||||
const chainMeta = await getChainMetadata(chain).catch(() => undefined);
|
||||
const contractData = await fetchDashboardContractMetadata(
|
||||
getContract({
|
||||
chain,
|
||||
address: contractAddress as string, // we filter above
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
return {
|
||||
label: `${contractData?.name} (${chainMeta?.slug || chainId})`,
|
||||
link: `/${chainId}/${contractAddress}`,
|
||||
value,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const aggregateFn = () =>
|
||||
new Set(
|
||||
data
|
||||
.filter((d) => d.contractAddress)
|
||||
.map((d) => `${d.chainId}:${d.contractAddress}`),
|
||||
).size;
|
||||
|
||||
return (
|
||||
<PieChartCard
|
||||
title="Contracts"
|
||||
data={reducedData}
|
||||
aggregateFn={aggregateFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { LoadingChartState } from "components/analytics/empty-chart-state";
|
||||
import { Suspense } from "react";
|
||||
import type { AnalyticsQueryParams } from "types/analytics";
|
||||
import { getClientTransactions } from "../../../../../../@/api/analytics";
|
||||
import { TransactionsChartsUI } from "./TransactionCharts";
|
||||
|
||||
export function TransactionsCharts(
|
||||
props: AnalyticsQueryParams & {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
},
|
||||
) {
|
||||
return (
|
||||
// TODO: Add better LoadingChartState
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[400px]">
|
||||
<LoadingChartState />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TransactionsChartCardAsync {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function TransactionsChartCardAsync(
|
||||
props: AnalyticsQueryParams & {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
},
|
||||
) {
|
||||
const [data, aggregatedData] = await Promise.all([
|
||||
getClientTransactions(props),
|
||||
getClientTransactions({
|
||||
...props,
|
||||
period: "all",
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!aggregatedData.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TransactionsChartsUI
|
||||
data={data}
|
||||
aggregatedData={aggregatedData}
|
||||
searchParams={props.searchParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+14
-1
@@ -1,5 +1,8 @@
|
||||
import type { Range } from "components/analytics/date-range-selector";
|
||||
import { InAppWalletAnalytics } from "components/embedded-wallets/Analytics";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProject } from "../../../../../../@/api/projects";
|
||||
import { getAPIKeyForProjectId } from "../../../../../api/lib/getAPIKeys";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -27,9 +30,19 @@ export default async function Page(props: {
|
||||
? (searchParams.interval as "day" | "week")
|
||||
: "week";
|
||||
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
if (!project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const apiKey = await getAPIKeyForProjectId(project.id);
|
||||
if (!apiKey) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<InAppWalletAnalytics
|
||||
clientId={params.project_slug}
|
||||
clientId={apiKey.key}
|
||||
interval={interval}
|
||||
range={range as Range}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChar
|
||||
import { EmptyState } from "../../components/Analytics/EmptyState";
|
||||
import { PieChartCard } from "../../components/Analytics/PieChartCard";
|
||||
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsCharts } from "./components/Transactions";
|
||||
|
||||
interface PageParams {
|
||||
team_slug: string;
|
||||
@@ -49,6 +50,8 @@ interface PageProps {
|
||||
searchParams: Promise<PageSearchParams>;
|
||||
}
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export default async function ProjectOverviewPage(props: PageProps) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
@@ -174,12 +177,6 @@ async function ProjectAnalytics(props: {
|
||||
link="https://portal.thirdweb.com/connect/quickstart"
|
||||
/>
|
||||
)}
|
||||
<RpcMethodBarChartCard
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
period={interval}
|
||||
clientId={project.publishableKey}
|
||||
/>
|
||||
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
|
||||
{walletConnections.status === "fulfilled" &&
|
||||
walletConnections.value.length > 0 ? (
|
||||
@@ -200,6 +197,13 @@ async function ProjectAnalytics(props: {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TransactionsCharts
|
||||
searchParams={searchParams}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
period={interval}
|
||||
clientId={project.publishableKey}
|
||||
/>
|
||||
{userOpUsageTimeSeries.status === "fulfilled" &&
|
||||
userOpUsage.status === "fulfilled" &&
|
||||
userOpUsage.value.length > 0 ? (
|
||||
@@ -216,6 +220,12 @@ async function ProjectAnalytics(props: {
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
/>
|
||||
)}
|
||||
<RpcMethodBarChartCard
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
period={interval}
|
||||
clientId={project.publishableKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -417,7 +427,7 @@ async function TotalSponsoredCard({
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
isCurrency
|
||||
title="Total Sponsored"
|
||||
title="Gas Sponsored"
|
||||
chartConfig={chartConfig}
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
data={timeSeriesData}
|
||||
|
||||
+10
@@ -60,6 +60,16 @@ function Story() {
|
||||
payConfig: "/payConfig",
|
||||
}}
|
||||
onKeyUpdated={undefined}
|
||||
rotateSecretKey={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return {
|
||||
data: {
|
||||
secret: new Array(86).fill("x").join(""),
|
||||
secretHash: new Array(64).fill("x").join(""),
|
||||
secretMasked: "123...4567",
|
||||
},
|
||||
};
|
||||
}}
|
||||
showNebulaSettings={false}
|
||||
/>
|
||||
|
||||
|
||||
+275
-6
@@ -1,12 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -14,13 +23,14 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ApiKey, UpdateKeyInput } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
useRevokeApiKey,
|
||||
useUpdateApiKey,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
|
||||
import { SERVICES } from "@thirdweb-dev/service-utils";
|
||||
import {
|
||||
type ServiceName,
|
||||
@@ -28,8 +38,13 @@ import {
|
||||
} from "@thirdweb-dev/service-utils";
|
||||
import { format } from "date-fns";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
RefreshCcwIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -47,11 +62,20 @@ type EditProjectUIPaths = {
|
||||
afterDeleteRedirectTo: string;
|
||||
};
|
||||
|
||||
type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ProjectGeneralSettingsPage(props: {
|
||||
apiKey: ApiKey;
|
||||
paths: EditProjectUIPaths;
|
||||
onKeyUpdated: (() => void) | undefined;
|
||||
showNebulaSettings: boolean;
|
||||
projectId: string;
|
||||
}) {
|
||||
const updateMutation = useUpdateApiKey();
|
||||
const deleteMutation = useRevokeApiKey();
|
||||
@@ -64,6 +88,24 @@ export function ProjectGeneralSettingsPage(props: {
|
||||
paths={props.paths}
|
||||
onKeyUpdated={props.onKeyUpdated}
|
||||
showNebulaSettings={props.showNebulaSettings}
|
||||
rotateSecretKey={async () => {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
pathname: "/v2/keys/rotate-secret-key",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
projectId: props.projectId,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +126,7 @@ interface EditApiKeyProps {
|
||||
paths: EditProjectUIPaths;
|
||||
onKeyUpdated: (() => void) | undefined;
|
||||
showNebulaSettings: boolean;
|
||||
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
|
||||
}
|
||||
|
||||
type UpdateAPIForm = UseFormReturn<ProjectSettingsPageFormSchema>;
|
||||
@@ -216,7 +259,10 @@ export const ProjectGeneralSettingsPageUI: React.FC<EditApiKeyProps> = (
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<APIKeyDetails apiKey={apiKey} />
|
||||
<APIKeyDetails
|
||||
apiKey={apiKey}
|
||||
rotateSecretKey={props.rotateSecretKey}
|
||||
/>
|
||||
|
||||
<AllowedDomainsSetting
|
||||
form={form}
|
||||
@@ -609,10 +655,13 @@ function EnabledServicesSetting(props: {
|
||||
|
||||
function APIKeyDetails({
|
||||
apiKey,
|
||||
rotateSecretKey,
|
||||
}: {
|
||||
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
|
||||
apiKey: ApiKey;
|
||||
}) {
|
||||
const { createdAt, updatedAt, lastAccessedAt } = apiKey;
|
||||
const [secretKeyMasked, setSecretKeyMasked] = useState(apiKey.secretMasked);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 rounded-lg border border-border bg-card px-4 py-6 lg:px-6">
|
||||
@@ -632,7 +681,7 @@ function APIKeyDetails({
|
||||
</div>
|
||||
|
||||
{/* NOTE: for very old api keys the secret might be `null`, if that's the case we skip it */}
|
||||
{apiKey.secretMasked && (
|
||||
{secretKeyMasked && (
|
||||
<div>
|
||||
<h3>Secret Key</h3>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
@@ -641,8 +690,17 @@ function APIKeyDetails({
|
||||
the time of creation for the full secret key.
|
||||
</p>
|
||||
|
||||
<div className="max-w-[350px] rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
|
||||
{apiKey.secretMasked}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm lg:w-[350px]">
|
||||
{secretKeyMasked}
|
||||
</div>
|
||||
|
||||
<RotateSecretKeyButton
|
||||
rotateSecretKey={rotateSecretKey}
|
||||
onSuccess={(data) => {
|
||||
setSecretKeyMasked(data.data.secretMasked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -726,3 +784,214 @@ function DeleteProject(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RotateSecretKeyButton(props: {
|
||||
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
|
||||
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isModalCloseAllowed, setIsModalCloseAllowed] = useState(true);
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(v) => {
|
||||
if (!isModalCloseAllowed) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto gap-2 rounded-lg bg-background px-4 py-3"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
Rotate Secret Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
dialogCloseClassName={cn(!isModalCloseAllowed && "hidden")}
|
||||
>
|
||||
<RotateSecretKeyModalContent
|
||||
rotateSecretKey={props.rotateSecretKey}
|
||||
closeModal={() => {
|
||||
setIsOpen(false);
|
||||
setIsModalCloseAllowed(true);
|
||||
}}
|
||||
disableModalClose={() => setIsModalCloseAllowed(false)}
|
||||
onSuccess={props.onSuccess}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type RotateSecretKeyScreen =
|
||||
| { id: "initial" }
|
||||
| { id: "save-newkey"; secretKey: string };
|
||||
|
||||
function RotateSecretKeyModalContent(props: {
|
||||
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
|
||||
closeModal: () => void;
|
||||
disableModalClose: () => void;
|
||||
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
|
||||
}) {
|
||||
const [screen, setScreen] = useState<RotateSecretKeyScreen>({
|
||||
id: "initial",
|
||||
});
|
||||
|
||||
if (screen.id === "save-newkey") {
|
||||
return (
|
||||
<SaveNewKeyScreen
|
||||
secretKey={screen.secretKey}
|
||||
closeModal={props.closeModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.id === "initial") {
|
||||
return (
|
||||
<RotateSecretKeyInitialScreen
|
||||
rotateSecretKey={props.rotateSecretKey}
|
||||
onSuccess={(data) => {
|
||||
props.disableModalClose();
|
||||
props.onSuccess(data);
|
||||
setScreen({ id: "save-newkey", secretKey: data.data.secret });
|
||||
}}
|
||||
closeModal={props.closeModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RotateSecretKeyInitialScreen(props: {
|
||||
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
|
||||
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
|
||||
closeModal: () => void;
|
||||
}) {
|
||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||
const rotateKeyMutation = useMutation({
|
||||
mutationFn: props.rotateSecretKey,
|
||||
onSuccess: (data) => {
|
||||
props.onSuccess(data);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
toast.error("Failed to rotate secret key");
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rotate Secret Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Current secret key will stop working</AlertTitle>
|
||||
<AlertDescription>
|
||||
Rotating the secret key will invalidate the current secret key and
|
||||
generate a new one. This action is irreversible.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
checked={isConfirmed}
|
||||
onCheckedChange={(v) => setIsConfirmed(!!v)}
|
||||
/>
|
||||
I understand the consequences of rotating the secret key
|
||||
</CheckboxWithLabel>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-6">
|
||||
<Button variant="outline" onClick={props.closeModal}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
disabled={!isConfirmed || rotateKeyMutation.isPending}
|
||||
onClick={() => {
|
||||
rotateKeyMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{rotateKeyMutation.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
)}
|
||||
Rotate Secret Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveNewKeyScreen(props: {
|
||||
secretKey: string;
|
||||
closeModal: () => void;
|
||||
}) {
|
||||
const [isSecretStored, setIsSecretStored] = useState(false);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex flex-col p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save New Secret Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<CopyTextButton
|
||||
textToCopy={props.secretKey}
|
||||
className="!h-auto w-full justify-between bg-card px-3 py-3 font-mono"
|
||||
textToShow={props.secretKey}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Secret Key"
|
||||
/>
|
||||
<div className="h-4" />
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Do not share or expose your secret key</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mb-5">
|
||||
Secret keys cannot be recovered. If you lose your secret key, you
|
||||
will need to rotate the secret key or create a new Project.
|
||||
</div>
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
checked={isSecretStored}
|
||||
onCheckedChange={(v) => {
|
||||
setIsSecretStored(!!v);
|
||||
}}
|
||||
/>
|
||||
I confirm that I've securely stored my secret key
|
||||
</CheckboxWithLabel>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={!isSecretStored}
|
||||
onClick={props.closeModal}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -9,9 +9,10 @@ export function ProjectGeneralSettingsPageForTeams(props: {
|
||||
team: Team;
|
||||
project_slug: string;
|
||||
apiKey: ApiKey;
|
||||
projectId: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const { team, project_slug, apiKey } = props;
|
||||
const { team, project_slug, apiKey, projectId } = props;
|
||||
const projectLayout = `/team/${team.slug}/${project_slug}`;
|
||||
|
||||
// TODO - add a Project Image form field on this page
|
||||
@@ -19,6 +20,7 @@ export function ProjectGeneralSettingsPageForTeams(props: {
|
||||
return (
|
||||
<ProjectGeneralSettingsPage
|
||||
apiKey={apiKey}
|
||||
projectId={projectId}
|
||||
paths={{
|
||||
aaConfig: `${projectLayout}/connect/account-abstraction/settings`,
|
||||
inAppConfig: `${projectLayout}/connect/in-app-wallets/settings`,
|
||||
|
||||
@@ -31,6 +31,7 @@ export default async function Page(props: {
|
||||
<ProjectGeneralSettingsPageForTeams
|
||||
apiKey={apiKey}
|
||||
project_slug={project_slug}
|
||||
projectId={project.id}
|
||||
team={team}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTeamBySlug } from "@/api/team";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TWAutoConnect } from "../../components/autoconnect";
|
||||
import { SaveLastVisitedTeamPage } from "../components/last-visited-page/SaveLastVisitedPage";
|
||||
|
||||
export default async function RootTeamLayout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -19,6 +20,7 @@ export default async function RootTeamLayout(props: {
|
||||
<div className="flex grow flex-col">{props.children}</div>
|
||||
<TWAutoConnect />
|
||||
<AppFooter />
|
||||
<SaveLastVisitedTeamPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { PieChart } from "./PieChart";
|
||||
import { Stat } from "./Stat";
|
||||
|
||||
@@ -11,6 +12,7 @@ type ChartData = {
|
||||
value: number;
|
||||
label: string;
|
||||
fill?: string;
|
||||
link?: string;
|
||||
};
|
||||
export function PieChartCard({
|
||||
title,
|
||||
@@ -22,11 +24,11 @@ export function PieChartCard({
|
||||
aggregateFn?: (data: ChartData[]) => number;
|
||||
}) {
|
||||
const processedData = (() => {
|
||||
if (data.length <= 9) return data;
|
||||
|
||||
// Sort by value descending
|
||||
const sorted = [...data].sort((a, b) => b.value - a.value);
|
||||
|
||||
if (sorted.length <= 9) return sorted;
|
||||
|
||||
// Take top 9
|
||||
const top10 = sorted.slice(0, 9).map((item) => ({
|
||||
...item,
|
||||
@@ -62,13 +64,27 @@ export function PieChartCard({
|
||||
<CardFooter className="no-scrollbar flex max-w-full justify-center p-6 pt-0 max-md:overflow-x-auto">
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2.5">
|
||||
{processedData.map(
|
||||
({ label, fill }: { label: string; fill?: string }) => (
|
||||
({
|
||||
label,
|
||||
fill,
|
||||
link,
|
||||
}: { label: string; fill?: string; link?: string }) => (
|
||||
<div key={fill} className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ background: fill }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
{link ? (
|
||||
<Link
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="text-muted-foreground text-xs hover:text-link-foreground"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -65,6 +65,11 @@ export function TeamHeaderLoggedIn(props: {
|
||||
|
||||
<LazyCreateAPIKeyDialog
|
||||
open={createProjectDialogState.isOpen}
|
||||
teamSlug={
|
||||
createProjectDialogState.isOpen
|
||||
? createProjectDialogState.team.slug
|
||||
: undefined
|
||||
}
|
||||
onOpenChange={() =>
|
||||
setCreateProjectDialogState({
|
||||
isOpen: false,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { setCookie } from "../../../../lib/cookie";
|
||||
import { LAST_VISITED_TEAM_PAGE_PATH } from "./consts";
|
||||
|
||||
export function SaveLastVisitedTeamPage() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
setCookie(LAST_VISITED_TEAM_PAGE_PATH, pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Only keep constants here - this file is imported in middleware too
|
||||
|
||||
export const LAST_VISITED_TEAM_PAGE_PATH = "last-visited-team-page-path";
|
||||
+8
-6
@@ -25,7 +25,7 @@ import { CircleAlertIcon, ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { ZERO_ADDRESS, getContract } from "thirdweb";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import {
|
||||
deployContractfromDeployMetadata,
|
||||
@@ -567,11 +567,13 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
const contractAddr = await deployMutation.mutateAsync(formData);
|
||||
|
||||
// send verification request - no need to await
|
||||
verifyContract({
|
||||
address: contractAddr,
|
||||
chain: walletChain,
|
||||
client: thirdwebClient,
|
||||
});
|
||||
verifyContract(
|
||||
getContract({
|
||||
address: contractAddr,
|
||||
chain: walletChain,
|
||||
client: thirdwebClient,
|
||||
}),
|
||||
);
|
||||
|
||||
trackEvent({
|
||||
category: "custom-contract",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -24,7 +26,6 @@ import {
|
||||
useAllContractList,
|
||||
} from "@3rdweb-sdk/react/hooks/useRegistry";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
|
||||
import { useChainSlug } from "hooks/chains/chainSlug";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -82,17 +83,22 @@ const importFormSchema = z.object({
|
||||
message: "Invalid contract address",
|
||||
},
|
||||
),
|
||||
chainId: z.coerce.number(),
|
||||
});
|
||||
|
||||
function ImportForm() {
|
||||
const router = useDashboardRouter();
|
||||
const chainId = useActiveWalletChain()?.id;
|
||||
const chainSlug = useChainSlug(chainId || 1);
|
||||
const activeChainId = useActiveWalletChain()?.id;
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(importFormSchema),
|
||||
values: {
|
||||
contractAddress: "",
|
||||
chainId: activeChainId || 1,
|
||||
},
|
||||
});
|
||||
const chainSlug = useChainSlug(form.watch("chainId"));
|
||||
const addToDashboard = useAddContractMutation();
|
||||
const address = useActiveAccount()?.address;
|
||||
const registry = useAllContractList(address);
|
||||
@@ -107,9 +113,7 @@ function ImportForm() {
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
if (!chainId) {
|
||||
throw new Error("No chain ID");
|
||||
}
|
||||
const { chainId } = data;
|
||||
let contractAddress: string;
|
||||
|
||||
try {
|
||||
@@ -199,7 +203,11 @@ function ImportForm() {
|
||||
<div className="h-3" />
|
||||
<div>
|
||||
<Label className="mb-3 inline-block">Network</Label>
|
||||
<NetworkSelectorButton />
|
||||
<SingleNetworkSelector
|
||||
chainId={form.watch("chainId")}
|
||||
onChange={(v) => form.setValue("chainId", v)}
|
||||
side="top"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AbiFunction, AbiParameter } from "abitype";
|
||||
import { type AbiFunction, type AbiParameter, formatAbiItem } from "abitype";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { camelToTitle } from "contract-ui/components/solidity-inputs/helpers";
|
||||
@@ -28,13 +28,12 @@ import {
|
||||
type ThirdwebContract,
|
||||
prepareContractCall,
|
||||
readContract,
|
||||
resolveMethod,
|
||||
simulateTransaction,
|
||||
toSerializableTransaction,
|
||||
toWei,
|
||||
} from "thirdweb";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { parseAbiParams, stringify } from "thirdweb/utils";
|
||||
import { parseAbiParams, stringify, toFunctionSelector } from "thirdweb/utils";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -135,7 +134,12 @@ interface InteractiveAbiFunctionProps {
|
||||
twAccount: Account | undefined;
|
||||
}
|
||||
|
||||
function useAsyncRead(contract: ThirdwebContract, functionName: string) {
|
||||
function useAsyncRead(contract: ThirdwebContract, abiFunction: AbiFunction) {
|
||||
const formattedAbi = formatAbiItem({
|
||||
...abiFunction,
|
||||
type: "function",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
} as any);
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
args,
|
||||
@@ -144,7 +148,8 @@ function useAsyncRead(contract: ThirdwebContract, functionName: string) {
|
||||
const params = parseAbiParams(types, args);
|
||||
return readContract({
|
||||
contract,
|
||||
method: resolveMethod(functionName),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing
|
||||
method: formattedAbi as any,
|
||||
params,
|
||||
});
|
||||
},
|
||||
@@ -156,21 +161,32 @@ function useSimulateTransaction() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
contract,
|
||||
functionName,
|
||||
abiFunction,
|
||||
params,
|
||||
value,
|
||||
}: {
|
||||
contract: ThirdwebContract;
|
||||
functionName: string;
|
||||
abiFunction: AbiFunction;
|
||||
params: unknown[];
|
||||
value?: bigint;
|
||||
}) => {
|
||||
if (!from) {
|
||||
return toast.error("No account connected");
|
||||
}
|
||||
const formattedAbi = formatAbiItem({
|
||||
...abiFunction,
|
||||
type: "function",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
} as any);
|
||||
console.log(
|
||||
"formattedAbi",
|
||||
formattedAbi,
|
||||
toFunctionSelector(abiFunction),
|
||||
);
|
||||
const transaction = prepareContractCall({
|
||||
contract,
|
||||
method: resolveMethod(functionName),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing
|
||||
method: formattedAbi as any,
|
||||
params,
|
||||
value,
|
||||
});
|
||||
@@ -179,10 +195,16 @@ function useSimulateTransaction() {
|
||||
simulateTransaction({
|
||||
from,
|
||||
transaction,
|
||||
}).catch((e) => {
|
||||
console.error("Error simulating transaction", e);
|
||||
throw e;
|
||||
}),
|
||||
toSerializableTransaction({
|
||||
from,
|
||||
transaction,
|
||||
}).catch((e) => {
|
||||
console.error("Error serializing transaction", e);
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
return `--- ✅ Simulation succeeded ---
|
||||
@@ -257,7 +279,7 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
data: readData,
|
||||
isPending: readLoading,
|
||||
error: readError,
|
||||
} = useAsyncRead(contract, abiFunction.name);
|
||||
} = useAsyncRead(contract, abiFunction);
|
||||
|
||||
const txSimulation = useSimulateTransaction();
|
||||
|
||||
@@ -311,12 +333,18 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
if (!abiFunction.name) {
|
||||
return toast.error("Cannot detect function name");
|
||||
}
|
||||
const formattedAbi = formatAbiItem({
|
||||
...abiFunction,
|
||||
type: "function",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
} as any);
|
||||
const types = abiFunction.inputs.map((o) => o.type);
|
||||
const formatted = formatContractCall(d.params);
|
||||
const params = parseAbiParams(types, formatted);
|
||||
const transaction = prepareContractCall({
|
||||
contract,
|
||||
method: resolveMethod(abiFunction.name),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing
|
||||
method: formattedAbi as any,
|
||||
params,
|
||||
value: d.value ? toWei(d.value) : undefined,
|
||||
});
|
||||
@@ -334,7 +362,7 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
txSimulation.mutate({
|
||||
contract,
|
||||
params,
|
||||
functionName: abiFunction.name,
|
||||
abiFunction,
|
||||
value: d.value ? toWei(d.value) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function Changelog() {
|
||||
|
||||
async function getChangelog() {
|
||||
const res = await fetch(
|
||||
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=10",
|
||||
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=7",
|
||||
);
|
||||
const json = await res.json();
|
||||
return json.posts as ChangelogItem[];
|
||||
|
||||
@@ -20,7 +20,10 @@ export async function InAppWalletAnalytics({
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}).catch(() => null);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -57,20 +57,18 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
|
||||
}, [validTeamPlan]);
|
||||
|
||||
const growthCardCta: CtaLink | undefined = useMemo(() => {
|
||||
const trialTitle = "Claim your 1-month free";
|
||||
|
||||
switch (validTeamPlan) {
|
||||
// free > growth
|
||||
case "free": {
|
||||
return {
|
||||
label: team.growthTrialEligible ? trialTitle : "Get started",
|
||||
label: "Get started",
|
||||
};
|
||||
}
|
||||
|
||||
// starter > growth
|
||||
case "starter": {
|
||||
return {
|
||||
label: team.growthTrialEligible ? trialTitle : "Upgrade",
|
||||
label: "Upgrade",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +85,7 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [team, validTeamPlan]);
|
||||
}, [validTeamPlan]);
|
||||
|
||||
const proCta: CtaLink | undefined = useMemo(() => {
|
||||
// pro > pro
|
||||
@@ -138,20 +136,16 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
|
||||
title: growthCardCta.label,
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: team.growthTrialEligible
|
||||
? "claimGrowthTrial"
|
||||
: "growthPlan",
|
||||
label: "growthPlan",
|
||||
},
|
||||
variant: "default",
|
||||
hint: team.growthTrialEligible
|
||||
? "Your free trial will end after 30 days."
|
||||
: undefined,
|
||||
hint: undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
canTrialGrowth={team.growthTrialEligible || false}
|
||||
canTrialGrowth={false}
|
||||
// upsell growth plan if user is on free plan
|
||||
highlighted={validTeamPlan === "free"}
|
||||
highlighted={validTeamPlan === "free" || validTeamPlan === "starter"}
|
||||
teamSlug={team.slug}
|
||||
redirectToCheckout={redirectToCheckout}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { redirectToBillingPortal, redirectToCheckout } from "@/actions/billing";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamSubscription } from "@/api/team-subscription";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard";
|
||||
import { getValidTeamPlan } from "../../../../app/team/components/TeamHeader/getValidTeamPlan";
|
||||
import { CouponSection } from "./CouponCard";
|
||||
@@ -28,6 +31,25 @@ export const Billing: React.FC<BillingProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<Alert variant="info">
|
||||
<AlertCircleIcon className="size-5" />
|
||||
<AlertTitle>Manage your plan</AlertTitle>
|
||||
<AlertDescription className="leading-relaxed">
|
||||
<span>
|
||||
Adjust your plan here to avoid unnecessary charges. For details, see{" "}
|
||||
</span>
|
||||
<span>
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/account/billing/manage-billing"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{" "}
|
||||
how to manage billing
|
||||
</Link>{" "}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<PlanInfoCard
|
||||
team={team}
|
||||
subscriptions={subscriptions}
|
||||
@@ -35,9 +57,12 @@ export const Billing: React.FC<BillingProps> = ({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
<h2 className="mb-2 font-semibold text-2xl tracking-tight">
|
||||
{validPlan === "free" ? "Select a Plan" : "Plans"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Upgrade or downgrade your plan here to better fit your needs.
|
||||
</p>
|
||||
<div className="h-3" />
|
||||
<BillingPricing
|
||||
team={team}
|
||||
|
||||
@@ -53,6 +53,7 @@ function Story(props: {
|
||||
createKeyMutation={mutation}
|
||||
prefill={props.prefill}
|
||||
enableNebulaServiceByDefault={false}
|
||||
teamSlug="foo"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import {
|
||||
type ApiKey,
|
||||
type CreateKeyInput,
|
||||
@@ -30,10 +33,10 @@ import {
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { type UseMutationResult, useQuery } from "@tanstack/react-query";
|
||||
import { SERVICES } from "@thirdweb-dev/service-utils";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -54,6 +57,7 @@ export type CreateAPIKeyDialogProps = {
|
||||
onCreateAndComplete?: () => void;
|
||||
prefill?: CreateAPIKeyPrefillOptions;
|
||||
enableNebulaServiceByDefault: boolean;
|
||||
teamSlug: string | undefined;
|
||||
};
|
||||
|
||||
const CreateAPIKeyDialog = (props: CreateAPIKeyDialogProps) => {
|
||||
@@ -78,6 +82,7 @@ export const CreateAPIKeyDialogUI = (props: {
|
||||
>;
|
||||
prefill?: CreateAPIKeyPrefillOptions;
|
||||
enableNebulaServiceByDefault: boolean;
|
||||
teamSlug: string | undefined;
|
||||
}) => {
|
||||
const [screen, setScreen] = useState<
|
||||
{ id: "create" } | { id: "api-details"; key: ApiKey }
|
||||
@@ -97,7 +102,7 @@ export const CreateAPIKeyDialogUI = (props: {
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="z-[10001] p-0"
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
dialogCloseClassName={screen.id === "api-details" ? "hidden" : ""}
|
||||
>
|
||||
@@ -116,6 +121,7 @@ export const CreateAPIKeyDialogUI = (props: {
|
||||
{screen.id === "api-details" && (
|
||||
<APIKeyDetails
|
||||
apiKey={screen.key}
|
||||
teamSlug={props.teamSlug}
|
||||
onComplete={() => {
|
||||
onOpenChange(false);
|
||||
setScreen({ id: "create" });
|
||||
@@ -402,9 +408,35 @@ function DomainsAlert(props: {
|
||||
function APIKeyDetails(props: {
|
||||
apiKey: ApiKey;
|
||||
onComplete: () => void;
|
||||
teamSlug: string | undefined;
|
||||
}) {
|
||||
const { apiKey } = props;
|
||||
const [secretStored, setSecretStored] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
// get the project.slug for the apiKey to render "View Project" button
|
||||
const projectQuery = useQuery({
|
||||
queryKey: ["project", props.teamSlug, apiKey.id],
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy<{
|
||||
result: Project[];
|
||||
}>({
|
||||
method: "GET",
|
||||
pathname: `/v1/teams/${props.teamSlug}/projects`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const projects = res.data.result;
|
||||
const project = projects.find((p) => p.publishableKey === apiKey.key);
|
||||
return project || null;
|
||||
},
|
||||
enabled: !!props.teamSlug,
|
||||
});
|
||||
|
||||
const projectSlug = projectQuery.data?.slug;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -454,7 +486,7 @@ function APIKeyDetails(props: {
|
||||
<AlertDescription>
|
||||
<div className="mb-5">
|
||||
Secret keys cannot be recovered. If you lose your secret key, you
|
||||
will need to create a project
|
||||
will need to rotate the secret key or create a new Project.
|
||||
</div>
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
@@ -474,10 +506,31 @@ function APIKeyDetails(props: {
|
||||
type="button"
|
||||
onClick={props.onComplete}
|
||||
disabled={!secretStored}
|
||||
variant="outline"
|
||||
className="min-w-28 gap-2"
|
||||
>
|
||||
Complete
|
||||
{props.teamSlug ? "Close" : "Complete"}
|
||||
</Button>
|
||||
|
||||
{props.teamSlug && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!projectSlug) {
|
||||
return;
|
||||
}
|
||||
router.push(`/team/${props.teamSlug}/${projectSlug}`);
|
||||
}}
|
||||
disabled={!secretStored || !projectSlug}
|
||||
className="min-w-28 gap-2"
|
||||
>
|
||||
View Project
|
||||
{projectQuery.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/example.csv"
|
||||
href="/assets/examples/example.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
@@ -71,7 +71,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/example.json"
|
||||
href="/assets/examples/example.json"
|
||||
>
|
||||
Download example.json
|
||||
</a>
|
||||
@@ -113,7 +113,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/example-with-ipfs.csv"
|
||||
href="/assets/examples/example-with-ipfs.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
@@ -125,7 +125,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href="/example-with-maps.csv"
|
||||
href="/assets/examples/example-with-maps.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
|
||||
@@ -24,6 +24,13 @@ export async function fetchAnalytics(
|
||||
decodeURIComponent(value),
|
||||
);
|
||||
}
|
||||
// client id DEBUG OVERRIDE
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("clientId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("accountId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
// "clientId",
|
||||
// "...",
|
||||
// );
|
||||
|
||||
return fetch(ANALYTICS_SERVICE_URL, {
|
||||
...init,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
export function getCookie(name: string) {
|
||||
try {
|
||||
const value = document.cookie;
|
||||
const cookies = value.split(";");
|
||||
|
||||
for (const c of cookies) {
|
||||
const [_name, _val] = c.trim().split("=");
|
||||
if (_name === name && _val) {
|
||||
return decodeURIComponent(_val);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function setCookie(name: string, value: string, days = 365) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
document.cookie = `${name}=${value};path=/;expires=${date.toUTCString()}`;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getAddress } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { isValidENSName } from "thirdweb/utils";
|
||||
import { LAST_VISITED_TEAM_PAGE_PATH } from "./app/team/components/last-visited-page/consts";
|
||||
import { defineDashboardChain } from "./lib/defineDashboardChain";
|
||||
|
||||
// ignore assets, api - only intercept page routes
|
||||
@@ -17,8 +18,9 @@ export const config = {
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - assets/
|
||||
* - sitemap.xml, sitemap-0.xml, robots.txt, favicon.ico, some favicon images
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|assets).*)",
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|assets|robots.txt|\\.xml|\\.png).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -53,7 +55,6 @@ export async function middleware(request: NextRequest) {
|
||||
: null;
|
||||
|
||||
// utm collection
|
||||
// NOTE: this is not working for pages with rewrites in next.config.js - (framer pages)
|
||||
// if user is already signed in - don't bother capturing utm params
|
||||
if (!authCookie) {
|
||||
const searchParamsEntries = request.nextUrl.searchParams.entries();
|
||||
@@ -95,13 +96,15 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// remove '/' in front and then split by '/'
|
||||
|
||||
// if it's the homepage and we have an auth cookie, redirect to the dashboard
|
||||
if (paths.length === 1 && paths[0] === "" && authCookie) {
|
||||
const lastVisitedTeamPagePath = request.cookies.get(
|
||||
LAST_VISITED_TEAM_PAGE_PATH,
|
||||
)?.value;
|
||||
|
||||
return redirect(
|
||||
request,
|
||||
"/team",
|
||||
lastVisitedTeamPagePath || "/team",
|
||||
cookiesToSet
|
||||
? {
|
||||
cookiesToSet,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Store, useStore } from "@/lib/reactive";
|
||||
import { useEffect } from "react";
|
||||
import { getCookie, setCookie } from "../lib/cookie";
|
||||
|
||||
const loadedStoreKeys = new Set<string>();
|
||||
|
||||
@@ -51,27 +52,3 @@ export function SyncStoreToCookies<T>(props: {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
try {
|
||||
const value = document.cookie;
|
||||
const cookies = value.split(";");
|
||||
|
||||
for (const c of cookies) {
|
||||
const [_name, _val] = c.trim().split("=");
|
||||
if (_name === name && _val) {
|
||||
return decodeURIComponent(_val);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, days = 365) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
document.cookie = `${name}=${value};path=/;expires=${date.toUTCString()}`;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
billingEmail: "[email protected]",
|
||||
growthTrialEligible: true,
|
||||
growthTrialEligible: false,
|
||||
enabledScopes: [
|
||||
"pay",
|
||||
"storage",
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface UserOpStats {
|
||||
chainId?: string;
|
||||
}
|
||||
|
||||
export interface TransactionStats {
|
||||
date: string;
|
||||
chainId: number;
|
||||
contractAddress?: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RpcMethodStats {
|
||||
date: string;
|
||||
evmMethod: string;
|
||||
|
||||
@@ -13,26 +13,28 @@
|
||||
"fix": "eslint ./src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abstract-foundation/agw-client": "^1.3.0",
|
||||
"@abstract-foundation/agw-react": "^1.4.3",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.3",
|
||||
"@radix-ui/react-dialog": "1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.3",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "1.1.5",
|
||||
"@tanstack/react-query": "5.62.16",
|
||||
"@thirdweb-dev/engine": "^0.0.17",
|
||||
"@radix-ui/react-tooltip": "1.1.7",
|
||||
"@tanstack/react-query": "5.65.1",
|
||||
"@thirdweb-dev/engine": "^0.0.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "0.468.0",
|
||||
"next": "15.1.3",
|
||||
"lucide-react": "0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next-themes": "^0.4.4",
|
||||
"prettier": "3.3.3",
|
||||
"react": "19.0.0",
|
||||
@@ -46,15 +48,15 @@
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "19.0.2",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "15.1.4",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
|
||||
"postcss": "8.4.49",
|
||||
"tailwindcss": "3.4.16",
|
||||
"postcss": "8.5.1",
|
||||
"tailwindcss": "3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.7.2"
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ethereum } from "thirdweb/chains";
|
||||
import {
|
||||
arbitrumSepolia,
|
||||
baseSepolia,
|
||||
optimismSepolia,
|
||||
sepolia,
|
||||
} from "thirdweb/chains";
|
||||
import {
|
||||
ConnectButton,
|
||||
type ConnectButtonProps,
|
||||
@@ -108,12 +114,13 @@ export function RightSection(props: {
|
||||
showThirdwebBranding: connectOptions.ShowThirdwebBranding,
|
||||
requireApproval: connectOptions.requireApproval,
|
||||
}}
|
||||
chains={[sepolia, baseSepolia, optimismSepolia, arbitrumSepolia]}
|
||||
wallets={wallets}
|
||||
auth={connectOptions.enableAuth ? playgroundAuth : undefined}
|
||||
accountAbstraction={
|
||||
connectOptions.enableAccountAbstraction
|
||||
? {
|
||||
chain: ethereum,
|
||||
chain: sepolia,
|
||||
sponsorGas: true,
|
||||
}
|
||||
: undefined
|
||||
@@ -171,10 +178,16 @@ export function RightSection(props: {
|
||||
}}
|
||||
locale={connectOptions.localeId}
|
||||
auth={connectOptions.enableAuth ? playgroundAuth : undefined}
|
||||
chains={[
|
||||
sepolia,
|
||||
baseSepolia,
|
||||
optimismSepolia,
|
||||
arbitrumSepolia,
|
||||
]}
|
||||
accountAbstraction={
|
||||
connectOptions.enableAccountAbstraction
|
||||
? {
|
||||
chain: ethereum,
|
||||
chain: sepolia,
|
||||
sponsorGas: true,
|
||||
}
|
||||
: undefined
|
||||
@@ -206,7 +219,14 @@ export function RightSection(props: {
|
||||
* @internal
|
||||
*/
|
||||
export function getWallets(connectOptions: ConnectPlaygroundOptions) {
|
||||
const wallets = [...connectOptions.walletIds.map((id) => createWallet(id))];
|
||||
const wallets = [
|
||||
...connectOptions.walletIds.map((id) => {
|
||||
if (id === "xyz.abs") {
|
||||
return abstractWallet();
|
||||
}
|
||||
return createWallet(id);
|
||||
}),
|
||||
];
|
||||
|
||||
if (
|
||||
connectOptions.inAppWallet.enabled &&
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getContract } from "thirdweb";
|
||||
import { zkSyncSepolia } from "thirdweb/chains";
|
||||
@@ -15,6 +14,12 @@ import { shortenHex } from "thirdweb/utils";
|
||||
import { THIRDWEB_CLIENT } from "../../lib/client";
|
||||
import { WALLETS } from "../../lib/constants";
|
||||
|
||||
// const chain = abstractTestnet;
|
||||
// const editionDropContract = getContract({
|
||||
// client: THIRDWEB_CLIENT,
|
||||
// address: "0x8A24a7Df38fA5fCCcFD1259e90Fb6996fDdfcADa",
|
||||
// chain,
|
||||
// });
|
||||
const chain = zkSyncSepolia;
|
||||
const editionDropContract = getContract({
|
||||
client: THIRDWEB_CLIENT,
|
||||
@@ -83,6 +88,7 @@ export function SponsoredTxZksyncPreview() {
|
||||
metadata: nft?.metadata,
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error("error", error);
|
||||
alert(`Error: ${error.message}`);
|
||||
}}
|
||||
onClick={() => {
|
||||
|
||||
@@ -11,7 +11,7 @@ const getEcosystem = () => {
|
||||
return "ecosystem.catlovers";
|
||||
}
|
||||
// prod ecosystem
|
||||
return "ecosystem.new-age";
|
||||
return "ecosystem.thirdweb-engs";
|
||||
};
|
||||
|
||||
export function EcosystemConnectEmbed(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useActiveAccount,
|
||||
useActiveWallet,
|
||||
@@ -39,7 +38,9 @@ export function HooksPreview() {
|
||||
</>
|
||||
) : (
|
||||
<Button variant="default" onClick={connect}>
|
||||
Connect with Metamask
|
||||
{connectMutation.isConnecting
|
||||
? "Connecting..."
|
||||
: "Connect with Metamask"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,20 @@
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@next/next/recommended",
|
||||
"plugin:storybook/recommended"
|
||||
"plugin:mdx/recommended",
|
||||
"plugin:tailwindcss/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"svg-jsx/camel-case-dash": "error"
|
||||
}
|
||||
"svg-jsx/camel-case-dash": "error",
|
||||
"tailwindcss/classnames-order": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.js", "*.tsx", "*.jsx"],
|
||||
"extends": ["biome"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"next": true,
|
||||
"project": ["src/**"],
|
||||
"ignoreBinaries": ["only-allow", "biome"],
|
||||
"ignore": ["src/components/ui/**"],
|
||||
"ignoreDependencies": [
|
||||
"@thirdweb-dev/chains",
|
||||
"@thirdweb-dev/wallets",
|
||||
"thirdweb",
|
||||
"@types/mdx"
|
||||
],
|
||||
"entry": [
|
||||
"next.config.{js,ts,cjs,mjs}",
|
||||
"{instrumentation,middleware}.{js,ts}",
|
||||
"app/global-error.{js,jsx,ts,tsx}",
|
||||
"app/**/{error,layout,loading,not-found,page,template,default}.{js,jsx,ts,tsx,mdx}",
|
||||
"app/**/route.{js,jsx,ts,tsx}",
|
||||
"app/{manifest,sitemap,robots}.{js,ts}",
|
||||
"app/**/{icon,apple-icon}.{js,jsx,ts,tsx}",
|
||||
"app/**/{opengraph,twitter}-image.{js,jsx,ts,tsx}",
|
||||
"pages/**/*.{js,jsx,ts,tsx}",
|
||||
"src/{instrumentation,middleware}.{js,ts}",
|
||||
"src/app/global-error.{js,jsx,ts,tsx}",
|
||||
"src/app/**/{error,layout,loading,not-found,page,template,default}.{js,jsx,ts,tsx,mdx}",
|
||||
"src/app/**/route.{js,jsx,ts,tsx}",
|
||||
"src/app/{manifest,sitemap,robots}.{js,ts}",
|
||||
"src/app/**/{icon,apple-icon}.{js,jsx,ts,tsx}",
|
||||
"src/app/**/{opengraph,twitter}-image.{js,jsx,ts,tsx}",
|
||||
"src/pages/**/*.{js,jsx,ts,tsx}",
|
||||
"scripts/*.ts"
|
||||
]
|
||||
}
|
||||
+20
-19
@@ -10,31 +10,32 @@
|
||||
"build": "next build",
|
||||
"postbuild": "pnpm run extract-search-data && pnpm next-sitemap",
|
||||
"start": "next start",
|
||||
"lint": "biome check ./src && eslint ./src",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"create-index": "pnpm tsx scripts/createEmptySearchIndex.ts",
|
||||
"extract-search-data": "pnpm tsx scripts/extractSearchData.ts"
|
||||
"extract-search-data": "pnpm tsx scripts/extractSearchData.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dirtycajunrice/klee": "^1.0.6",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "15.1.0",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.3",
|
||||
"@radix-ui/react-select": "^2.1.3",
|
||||
"@next/mdx": "15.1.6",
|
||||
"@radix-ui/react-dialog": "1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@tanstack/react-query": "5.62.16",
|
||||
"@tanstack/react-query": "5.65.1",
|
||||
"@tryghost/content-api": "^1.11.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"flexsearch": "^0.7.43",
|
||||
"github-slugger": "^2.0.0",
|
||||
"lucide-react": "0.468.0",
|
||||
"next": "15.1.3",
|
||||
"lucide-react": "0.474.0",
|
||||
"next": "15.1.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"posthog-js": "1.67.1",
|
||||
@@ -43,7 +44,6 @@
|
||||
"react-dom": "19.0.0",
|
||||
"react-html-parser": "2.0.2",
|
||||
"remark-gfm": "3.0.1",
|
||||
"semver": "^7.6.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
@@ -53,11 +53,12 @@
|
||||
"typedoc-better-json": "0.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "15.1.6",
|
||||
"@types/flexsearch": "^0.7.6",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "19.0.2",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/react-html-parser": "^2.0.6",
|
||||
"@types/tryghost__content-api": "^1.3.16",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
@@ -65,14 +66,14 @@
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-config-next": "15.1.4",
|
||||
"eslint-plugin-mdx": "^3.1.5",
|
||||
"eslint-plugin-svg-jsx": "^1.2.4",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"knip": "5.43.6",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.4.49",
|
||||
"tailwindcss": "3.4.16",
|
||||
"postcss": "8.5.1",
|
||||
"tailwindcss": "3.4.17",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "5.7.2"
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
+21
-41
@@ -1,6 +1,11 @@
|
||||
// @ts-check
|
||||
|
||||
import { unrealEngineRedirects } from "./src/app/unreal-engine/redirects.mjs";
|
||||
const unrealEngineRedirects = {
|
||||
"/unreal/:path*": "/unreal-engine/:path*",
|
||||
"/unreal-engine/cpp/wallet-handle": "/unreal-engine/cpp/wallet-handles",
|
||||
"/unreal-engine/blueprints/private-key-wallet":
|
||||
"unreal-engine/blueprints/in-app-wallet",
|
||||
};
|
||||
|
||||
const reactRedirects = {
|
||||
"/react": "/react/v4",
|
||||
@@ -981,6 +986,20 @@ const v5RestructuredRedirects = {
|
||||
"/typescript/v5/react-native/:path*": "/react-native/v5/:path*",
|
||||
};
|
||||
|
||||
const v4ToV5Redirects = {
|
||||
"/typescript/v4": "/typescript/v5",
|
||||
"/typescript/v4/:path*": "/typescript/v5",
|
||||
"/react/v4": "/react/v5",
|
||||
"/react/v4/:path*": "/react/v5",
|
||||
"/react-native/v0": "/react-native/v5",
|
||||
"/react-native/v0/:path*": "/react-native/v5",
|
||||
"/wallet-sdk/:path*": "/connect",
|
||||
"/storage-sdk/v2": "/typescript/v5/storage",
|
||||
"/storage-sdk/v2/:path*": "/typescript/v5/storage",
|
||||
"/unity/v4": "/unity/v5",
|
||||
"/unity/v4/:path*": "/unity/v5",
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('next').NextConfig['redirects']}
|
||||
*/
|
||||
@@ -999,18 +1018,7 @@ export const redirects = async () => {
|
||||
...createRedirects(otherRedirects),
|
||||
...createRedirects(v5RestructuredRedirects),
|
||||
...createRedirects(unrealEngineRedirects),
|
||||
// references docs
|
||||
latestReference("react", "v4"),
|
||||
latestReference("react-native", "v0"),
|
||||
latestReference("typescript", "v5"),
|
||||
latestReference("wallets", "v2"),
|
||||
latestReference("storage", "v2"),
|
||||
// sdk docs
|
||||
latestSDK("react", "v4"),
|
||||
latestSDK("react-native", "v0"),
|
||||
latestSDK("typescript", "v5"),
|
||||
latestSDK("wallet-sdk", "v2"),
|
||||
latestSDK("storage-sdk", "v2"),
|
||||
...createRedirects(v4ToV5Redirects),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1026,31 +1034,3 @@ function createRedirects(linkMap, permanent = true) {
|
||||
}
|
||||
return redirects;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} pkg
|
||||
* @param {string} latestVersion
|
||||
* @returns
|
||||
*/
|
||||
function latestReference(pkg, latestVersion) {
|
||||
return {
|
||||
source: `/references/${pkg}/latest/:path*`,
|
||||
destination: `/references/${pkg}/${latestVersion}/:path*`,
|
||||
permanent: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} pkg
|
||||
* @param {string} latestVersion
|
||||
* @returns
|
||||
*/
|
||||
function latestSDK(pkg, latestVersion) {
|
||||
return {
|
||||
source: `/${pkg}/latest/:path*`,
|
||||
destination: `/${pkg}/${latestVersion}/:path*`,
|
||||
permanent: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,14 +9,20 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDownIcon, Menu } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { ChevronDownIcon, Menu, TableOfContentsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { GithubIcon } from "../components/Document/GithubButtonLink";
|
||||
import { CustomAccordion } from "../components/others/CustomAccordion";
|
||||
import { ThemeSwitcher } from "../components/others/theme/ThemeSwitcher";
|
||||
import {
|
||||
DotNetIcon,
|
||||
ReactIcon,
|
||||
TypeScriptIcon,
|
||||
UnityIcon,
|
||||
UnrealEngineIcon,
|
||||
} from "../icons";
|
||||
import { ThirdwebIcon } from "../icons/thirdweb";
|
||||
|
||||
const links = [
|
||||
@@ -65,42 +71,45 @@ const toolLinks = [
|
||||
},
|
||||
];
|
||||
|
||||
export const connectLinks = [
|
||||
export const connectLinks: Array<{
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
}> = [
|
||||
{
|
||||
name: "Overview",
|
||||
href: "/connect",
|
||||
icon: "/icons/navbar/nav-icon-dashboard.svg",
|
||||
icon: TableOfContentsIcon,
|
||||
},
|
||||
{
|
||||
name: "TypeScript",
|
||||
href: "/typescript/v5",
|
||||
icon: "/icons/navbar/nav-icon-typescript.svg",
|
||||
icon: TypeScriptIcon,
|
||||
},
|
||||
{
|
||||
name: "React",
|
||||
href: "/react/v5",
|
||||
icon: "/icons/navbar/nav-icon-react.svg",
|
||||
icon: ReactIcon,
|
||||
},
|
||||
{
|
||||
name: "React Native",
|
||||
href: "/react-native/v5",
|
||||
icon: "/icons/navbar/nav-icon-react.svg",
|
||||
// icon: "/icons/navbar/nav-icon-react-native.svg",
|
||||
icon: ReactIcon,
|
||||
},
|
||||
{
|
||||
name: ".NET",
|
||||
href: "/dotnet",
|
||||
icon: "/icons/navbar/nav-icon-dotnet.svg",
|
||||
icon: DotNetIcon,
|
||||
},
|
||||
{
|
||||
name: "Unity",
|
||||
href: "/unity",
|
||||
icon: "/icons/navbar/nav-icon-unity.svg",
|
||||
icon: UnityIcon,
|
||||
},
|
||||
{
|
||||
name: "Unreal Engine",
|
||||
href: "/unreal-engine",
|
||||
icon: "/icons/navbar/nav-icon-unreal-engine.svg",
|
||||
icon: UnrealEngineIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -123,7 +132,7 @@ export function Header() {
|
||||
const [showBurgerMenu, setShowBurgerMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="flex w-full items-center border-b bg-b-900">
|
||||
<header className="flex w-full items-center border-b bg-background">
|
||||
<div className="container flex items-center justify-between gap-6 p-4 xl:justify-start">
|
||||
<Link
|
||||
className="flex items-center gap-2"
|
||||
@@ -132,7 +141,7 @@ export function Header() {
|
||||
title="thirdweb Docs"
|
||||
>
|
||||
<ThirdwebIcon className="size-8" />
|
||||
<span className="font-bold text-[23px] text-f-100 leading-none tracking-tight">
|
||||
<span className="font-bold text-[23px] text-foreground leading-none tracking-tight">
|
||||
Docs
|
||||
</span>
|
||||
</Link>
|
||||
@@ -145,7 +154,7 @@ export function Header() {
|
||||
<Link
|
||||
href="https://github.com/thirdweb-dev"
|
||||
target="_blank"
|
||||
className="text-f-100"
|
||||
className="text-foreground"
|
||||
>
|
||||
<GithubIcon className="mx-3 size-6" />
|
||||
</Link>
|
||||
@@ -164,7 +173,7 @@ export function Header() {
|
||||
className={clsx(
|
||||
"grow gap-5",
|
||||
!showBurgerMenu ? "hidden xl:flex" : "flex",
|
||||
"fade-in-20 slide-in-from-top-3 fixed inset-0 top-sticky-top-height animate-in flex-col overflow-auto bg-b-800 p-6",
|
||||
"fade-in-20 slide-in-from-top-3 fixed inset-0 top-sticky-top-height animate-in flex-col overflow-auto bg-card p-6",
|
||||
"xl:static xl:animate-none xl:flex-row xl:justify-between xl:bg-transparent xl:p-0",
|
||||
)}
|
||||
>
|
||||
@@ -227,7 +236,7 @@ export function Header() {
|
||||
<Link
|
||||
href="https://github.com/thirdweb-dev"
|
||||
target="_blank"
|
||||
className="hidden text-f-300 transition-colors hover:text-f-100 xl:block"
|
||||
className="hidden text-muted-foreground transition-colors hover:text-foreground xl:block"
|
||||
>
|
||||
<GithubIcon className="mx-2 size-6" />
|
||||
</Link>
|
||||
@@ -241,7 +250,11 @@ export function Header() {
|
||||
function DropdownLinks(props: {
|
||||
onLinkClick?: () => void;
|
||||
category: string;
|
||||
links: readonly { name: string; href: string; icon?: string }[];
|
||||
links: readonly {
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -251,15 +264,15 @@ function DropdownLinks(props: {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="inline-flex items-center gap-1 p-0 font-medium text-f-300 text-sm hover:bg-transparent hover:text-f-100"
|
||||
className="inline-flex items-center gap-1 p-0 font-medium text-muted-foreground text-sm hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
{props.category}
|
||||
<ChevronDownIcon className="size-4 text-f-300 opacity-70" />
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="flex flex-col gap-1 bg-b-800 p-3"
|
||||
className="flex flex-col gap-1 bg-card p-3"
|
||||
style={{
|
||||
minWidth: "150px",
|
||||
}}
|
||||
@@ -267,26 +280,24 @@ function DropdownLinks(props: {
|
||||
{props.links.map((info) => {
|
||||
return (
|
||||
<DropdownMenuItem asChild key={info.name}>
|
||||
<Link
|
||||
href={info.href}
|
||||
target={info.href.startsWith("http") ? "_blank" : ""}
|
||||
prefetch={false}
|
||||
<div
|
||||
className={clsx(
|
||||
"flex cursor-pointer font-medium text-f-200",
|
||||
"hover:bg-b-600 hover:text-f-100",
|
||||
"relative flex cursor-pointer gap-2 font-medium text-foreground",
|
||||
"hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{info.icon && (
|
||||
<Image
|
||||
src={info.icon}
|
||||
width="20"
|
||||
height="20"
|
||||
alt=""
|
||||
className="mr-2"
|
||||
/>
|
||||
<info.icon className="size-5 text-foreground" />
|
||||
)}
|
||||
{info.name}
|
||||
</Link>
|
||||
<Link
|
||||
prefetch={false}
|
||||
target={info.href.startsWith("http") ? "_blank" : ""}
|
||||
href={info.href}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
{info.name}
|
||||
</Link>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -300,7 +311,7 @@ function DropdownLinks(props: {
|
||||
chevronPosition="right"
|
||||
containerClassName="border-none"
|
||||
trigger={props.category}
|
||||
triggerContainerClassName="py-0 text-base font-medium text-f-300"
|
||||
triggerContainerClassName="py-0 text-base font-medium text-muted-foreground"
|
||||
>
|
||||
<div className="pt-2">
|
||||
<div className="flex flex-col gap-4 border-l-2 pt-3 pl-4">
|
||||
@@ -327,7 +338,7 @@ function NavLink(props: {
|
||||
href: string;
|
||||
name: string;
|
||||
onClick?: () => void;
|
||||
icon?: string;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
@@ -336,20 +347,14 @@ function NavLink(props: {
|
||||
onClick={props.onClick}
|
||||
target={props.href.startsWith("http") ? "_blank" : ""}
|
||||
className={clsx(
|
||||
"font-medium text-base transition-colors hover:text-f-100 xl:text-sm",
|
||||
pathname === props.href ? "text-f-100" : "text-f-300 ",
|
||||
props.icon ? "flex flex-row gap-2" : "",
|
||||
"font-medium text-base transition-colors hover:text-foreground xl:text-sm",
|
||||
pathname === props.href ? "text-foreground" : "text-muted-foreground ",
|
||||
props.icon ? "flex flex-row gap-3" : "",
|
||||
)}
|
||||
>
|
||||
{props.icon ? (
|
||||
<>
|
||||
<Image
|
||||
src={props.icon}
|
||||
width="40"
|
||||
height="40"
|
||||
className="size-7"
|
||||
alt=""
|
||||
/>
|
||||
<props.icon className="size-6 text-muted-foreground" />
|
||||
<span className="my-auto">{props.name}</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user