Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3862bc7a94 | ||
|
|
5277eddb70 | ||
|
|
6c180b5c8a | ||
|
|
3489ecec98 | ||
|
|
f9b34ff2f1 | ||
|
|
f3abea3276 | ||
|
|
adc738e536 | ||
|
|
dd2fb1b13d | ||
|
|
69fdef07f5 | ||
|
|
362c086bf6 | ||
|
|
f9b609e7a2 | ||
|
|
916ecf494c | ||
|
|
d1ca9af2f4 | ||
|
|
317211a48e | ||
|
|
6cd64e7368 | ||
|
|
005acce714 | ||
|
|
cde2dd941f | ||
|
|
7130f9a929 | ||
|
|
1e0b1422f3 | ||
|
|
70a519ada4 | ||
|
|
678c00e322 | ||
|
|
5ab59ff400 | ||
|
|
73629e9f0e | ||
|
|
23029db9ef | ||
|
|
526b6b54cf | ||
|
|
f31116ea5a | ||
|
|
c1f7aaadc1 | ||
|
|
1ad109f3bc | ||
|
|
376bdb23d2 | ||
|
|
dcd6b99e67 | ||
|
|
88533d9113 | ||
|
|
6c3a8d36a7 | ||
|
|
a31ac0c065 | ||
|
|
7b72e88611 | ||
|
|
4b9a506fdf | ||
|
|
77da352366 | ||
|
|
72cc27a69a | ||
|
|
46d1dab11e | ||
|
|
a1c432ca17 | ||
|
|
243c661929 | ||
|
|
b64ffe369a | ||
|
|
30fafd16db | ||
|
|
99901dcfd7 | ||
|
|
f5072d882e | ||
|
|
e7f7a32dd4 | ||
|
|
d2098ad0bf | ||
|
+1 |
d6239785cf | ||
|
|
20fdd5b6d7 | ||
|
|
438c019f01 | ||
|
|
61044013c8 | ||
|
|
e6213db8af | ||
|
|
c46b2597be | ||
|
|
9ff97688ce | ||
|
|
b4f5cc7b5a | ||
|
|
256850d1c6 | ||
|
|
acb2b9f85f | ||
|
|
6ac62ffe96 | ||
|
|
80f4bd4e53 | ||
|
|
a3072c4335 | ||
|
|
3277cf6e8e | ||
|
|
77938c4d5c | ||
|
|
3080a9baba | ||
|
|
6cf92c6b11 | ||
|
+1 |
89ccc80d9c | ||
|
|
713ebe0a2d | ||
|
|
bfdac10dcc | ||
|
+1 |
64964da22e | ||
|
|
260c2fd370 | ||
|
|
c61e258ec3 | ||
|
|
fdd344184b | ||
|
|
0c853e8709 | ||
|
|
7d47b4cde0 | ||
|
|
feae304da2 | ||
|
|
8f605d989c | ||
|
|
d0c2ac63a9 | ||
|
|
170f14fb54 | ||
|
|
da2a2a0e86 | ||
|
|
e62fd3fe40 | ||
|
|
99a7cce624 | ||
|
|
61152dd098 | ||
|
|
05519fcdde | ||
|
|
7257747520 | ||
|
|
e71e7d70a6 | ||
|
|
8d47864967 | ||
|
|
4a8adc143f | ||
|
|
db538473cf | ||
|
|
2057546934 | ||
|
|
ba52e68eab | ||
|
|
0eff64824b | ||
|
|
ae7407dca4 | ||
|
|
a443b309dc | ||
|
|
ae2ff743c0 | ||
|
|
dfa8603593 | ||
|
|
9f1ca54c26 | ||
|
|
7df81b7323 | ||
|
|
ab74fcdaf8 | ||
|
|
1191d5dad9 | ||
|
|
20b5ba943a |
@@ -14,7 +14,7 @@ We use [Turborepo](https://turbo.build/repo/docs) to manage the repository, and
|
||||
|
||||
We use [pnpm](https://pnpm.io) for package management across the repo. `pnpm` is similar to `npm` or `yarn` but with more efficient disk space usage.
|
||||
|
||||
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](./packages/thirdweb). You can still find the legacy packages at [/legacy_packages](./legacy_packages).**
|
||||
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](../packages/thirdweb). You can still find the legacy packages at [/legacy_packages](../legacy_packages).**
|
||||
|
||||
This single package provides a performant & lightweight SDK to interact with any EVM chain across Node, React, and React Native. Learn more about how to use the thirdweb SDK in our [documentation](https://portal.thirdweb.com/typescript/v5).
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- run: pnpm test
|
||||
|
||||
- name: Code Coverage
|
||||
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
with:
|
||||
directory: packages/
|
||||
flags: packages
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
|
||||
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
|
||||
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
|
||||
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Create release Pull Request or publish to NPM
|
||||
id: changesets
|
||||
uses: changesets/action@746c25e23caa47dceb6a48ee85b4cbc5a9f5f293 #v1.4.0
|
||||
uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba #v1.4.0
|
||||
with:
|
||||
publish: pnpm release
|
||||
version: pnpm version-packages
|
||||
|
||||
+2
-1
@@ -31,4 +31,5 @@ storybook-static
|
||||
.aider*
|
||||
|
||||
tsconfig.tsbuildinfo
|
||||
.cursor
|
||||
.cursor
|
||||
apps/dashboard/node-compile-cache
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
🤖 Codex Agent Guidelines for thirdweb-dev/js
|
||||
|
||||
Welcome, AI copilots! This guide captures the coding standards, architectural decisions, and workflow conventions that every automated agent (and human contributor!) must follow. Unless a rule explicitly targets a sub‑project, it applies repo‑wide.
|
||||
|
||||
⸻
|
||||
|
||||
1. GitHub Workflow & Etiquette
|
||||
|
||||
- Pull‑request titles must start with the affected workspace in brackets (e.g. [SDK], [Dashboard], [Portal], [Playground]).
|
||||
- Begin the PR description with a one‑sentence summary, then add a checklist of changes and reference issues with Fixes #123.
|
||||
- Keep commits small and topical – one logical change per commit.
|
||||
- Branch names should follow area/brief-topic (e.g. sdk/fix-gas-estimate). Avoid personal names.
|
||||
- Request at least one core maintainer review. Do not self‑merge unless you are the sole owner of that package.
|
||||
- All CI checks (type‑check, Biome, tests) must pass before merging.
|
||||
|
||||
⸻
|
||||
|
||||
2. Formatting & Linting
|
||||
|
||||
- Biome governs formatting and linting; its rules live in biome.json.
|
||||
- Run pnpm biome check --apply before committing.
|
||||
- Avoid editor‑specific configs; rely on the shared settings.
|
||||
|
||||
⸻
|
||||
|
||||
3. TypeScript Style Guide
|
||||
|
||||
- Write idiomatic TypeScript: explicit function declarations and return types.
|
||||
- Limit each file to one stateless, single‑responsibility function for clarity and testability.
|
||||
- Re‑use shared types from @/types or local types.ts barrels.
|
||||
- Prefer type aliases over interface except for nominal shapes.
|
||||
- Avoid any and unknown unless unavoidable; narrow generics whenever possible.
|
||||
- Choose composition over inheritance; leverage utility types (Partial, Pick, etc.).
|
||||
|
||||
⸻
|
||||
|
||||
4. Testing Strategy
|
||||
|
||||
- Co‑locate tests: foo.ts ↔ foo.test.ts.
|
||||
- Use real function invocations with stub data; avoid brittle mocks.
|
||||
- For network interactions, use Mock Service Worker (MSW) to intercept fetch/HTTP calls, mocking only scenarios that are hard to reproduce.
|
||||
- Keep tests deterministic and side‑effect free; Jest is pre‑configured.
|
||||
|
||||
⸻
|
||||
|
||||
5. packages/thirdweb
|
||||
|
||||
5.1 Public API Surface
|
||||
|
||||
- Export everything via the exports/ directory, grouped by feature.
|
||||
- Every public symbol must have comprehensive TSDoc:
|
||||
- Include at least one @example block that compiles.
|
||||
- Tag with one custom annotation (@beta, @internal, @experimental, etc.).
|
||||
- Comment only ambiguous logic; avoid restating TypeScript in prose.
|
||||
|
||||
5.2 Performance
|
||||
|
||||
- Lazy‑load heavy dependencies inside async paths to keep the initial bundle lean:
|
||||
|
||||
`const { jsPDF } = await import("jspdf");`
|
||||
|
||||
⸻
|
||||
|
||||
6. apps/dashboard & apps/playground
|
||||
|
||||
6.1 Core UI Toolkit
|
||||
|
||||
- Import primitives from @/components/ui/\_ (e.g. Button, Input, Select, Tabs, Card, Sidebar, Badge, Separator).
|
||||
- Use NavLink for internal navigation so active states are handled automatically.
|
||||
- Group feature‑specific components under feature/components/\_ and expose a barrel index.ts when necessary.
|
||||
|
||||
6.2 Styling Conventions
|
||||
|
||||
- Tailwind CSS is the styling system – no inline styles or CSS modules.
|
||||
- Merge class names with cn() from @/lib/utils to keep conditional logic readable.
|
||||
- Stick to design tokens: backgrounds (bg-card), borders (border-border), muted text (text-muted-foreground), etc.
|
||||
- Expose a className prop on the root element of every component for overrides.
|
||||
|
||||
6.3 Component Patterns
|
||||
|
||||
- Server Components (run on the Node edge):
|
||||
-- Read cookies/headers with next/headers.
|
||||
-- Access server‑only environment variables or secrets.
|
||||
-- Perform heavy data fetching that should not ship to the client.
|
||||
-- Implement redirect logic with redirect() from next/navigation.
|
||||
-- Start files with import "server-only"; to prevent client bundling.
|
||||
- Client Components (run in the browser):
|
||||
-- Begin files with 'use client'; before imports.
|
||||
-- Handle interactive UI relying on React hooks (useState, useEffect, React Query, wallet hooks).
|
||||
-- Access browser APIs (localStorage, window, IntersectionObserver, etc.).
|
||||
-- Support fast transitions where data is prefetched on the client.
|
||||
|
||||
6.4 Data Fetching Guidelines
|
||||
|
||||
- Server Side
|
||||
-- Always call getAuthToken() to retrieve the JWT from cookies.
|
||||
-- Inject the token as an Authorization: Bearer header – never embed it in the URL.
|
||||
-- Return typed results (Project[], User[], …) – avoid any.
|
||||
- Client Side
|
||||
-- Wrap calls in React Query (@tanstack/react-query).
|
||||
-- Use descriptive, stable queryKeys for cache hits.
|
||||
-- Configure staleTime / cacheTime based on freshness requirements (default ≥ 60 s).
|
||||
-- Keep tokens secret by calling internal API routes or server actions.
|
||||
|
||||
⸻
|
||||
|
||||
7. Performance & Bundle Size
|
||||
|
||||
- Track bundle budgets via package.json#size-limit.
|
||||
- Lazy‑import optional features; avoid top‑level side‑effects.
|
||||
- De‑duplicate dependencies across packages through pnpm workspace hoisting.
|
||||
|
||||
⸻
|
||||
|
||||
8. Documentation & Developer Experience
|
||||
|
||||
- Each change in packages/\* should contain a changeset for the appropriate package, with the appropriate version bump
|
||||
|
||||
- patch for changes that don't impact the public API
|
||||
- minor for any new/modified public API
|
||||
|
||||
- Surface breaking changes prominently in PR descriptions.
|
||||
- For new UI components, add Storybook stories (\*.stories.tsx) alongside the code.
|
||||
+18
-18
@@ -71,7 +71,7 @@
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.503.0",
|
||||
"next": "15.3.1",
|
||||
"next": "15.3.2",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
@@ -105,30 +105,30 @@
|
||||
"thirdweb": "workspace:*",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "3.24.3"
|
||||
"zod": "3.25.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@next/bundle-analyzer": "15.3.1",
|
||||
"@next/eslint-plugin-next": "15.3.1",
|
||||
"@next/bundle-analyzer": "15.3.2",
|
||||
"@next/eslint-plugin-next": "15.3.2",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-onboarding": "8.6.12",
|
||||
"@storybook/addon-viewport": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/nextjs": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/addon-onboarding": "8.6.14",
|
||||
"@storybook/addon-viewport": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/nextjs": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "19.1.2",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
@@ -140,10 +140,10 @@
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"knip": "5.50.5",
|
||||
"knip": "5.56.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.3",
|
||||
"storybook": "8.6.12",
|
||||
"storybook": "8.6.14",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidatePathAction(
|
||||
path: string,
|
||||
type: "page" | "layout",
|
||||
) {
|
||||
revalidatePath(path, type);
|
||||
}
|
||||
@@ -48,15 +48,15 @@ async function fetchAnalytics(
|
||||
);
|
||||
}
|
||||
// client id DEBUG OVERRIDE
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("projectId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("teamId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
// analyticsServiceUrl.searchParams.delete("projectId");
|
||||
// analyticsServiceUrl.searchParams.delete("teamId");
|
||||
// analyticsServiceUrl.searchParams.append(
|
||||
// "teamId",
|
||||
// "team_clmb33q9w00gn1x0u2ri8z0k0",
|
||||
// "team_cm0lde33r02344w129k5hm2xz",
|
||||
// );
|
||||
// ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
// analyticsServiceUrl.searchParams.append(
|
||||
// "projectId",
|
||||
// "prj_clyqwud5y00u1na7nzxnzlz7o",
|
||||
// "prj_cm4rqwx9b002qrnsnr37wqpo6",
|
||||
// );
|
||||
|
||||
return fetch(analyticsServiceUrl, {
|
||||
@@ -377,7 +377,7 @@ export async function getEcosystemWalletUsage(args: {
|
||||
|
||||
export async function getUniversalBridgeUsage(args: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
@@ -395,11 +395,10 @@ export async function getUniversalBridgeUsage(args: {
|
||||
console.error(
|
||||
`Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as UniversalBridgeStats[];
|
||||
}
|
||||
|
||||
@@ -430,6 +429,5 @@ export async function getUniversalBridgeWalletUsage(args: {
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as UniversalBridgeWalletStats[];
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function getDefaultTeam() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getLastVisitedTeamOrDefaultTeam() {
|
||||
export async function getLastVisitedTeam() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -100,5 +100,5 @@ export async function getLastVisitedTeamOrDefaultTeam() {
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultTeam();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import "server-only";
|
||||
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { UB_BASE_URL } from "./constants";
|
||||
|
||||
type PaymentLink = {
|
||||
clientId: string;
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
receiver: string;
|
||||
destinationToken: {
|
||||
address: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
chainId: number;
|
||||
};
|
||||
amount: bigint | undefined;
|
||||
purchaseData: unknown;
|
||||
};
|
||||
|
||||
export async function getPaymentLink(props: {
|
||||
paymentId: string;
|
||||
}) {
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/links/${props.paymentId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const { data } = await res.json();
|
||||
return {
|
||||
clientId: data.clientId,
|
||||
title: data.title,
|
||||
imageUrl: data.imageUrl,
|
||||
receiver: data.receiver,
|
||||
destinationToken: data.destinationToken,
|
||||
amount: data.amount ? BigInt(data.amount) : undefined,
|
||||
purchaseData: data.purchaseData,
|
||||
} as PaymentLink;
|
||||
}
|
||||
@@ -35,14 +35,28 @@ function Variant(props: {
|
||||
label: string;
|
||||
selectedChainId?: number;
|
||||
}) {
|
||||
const [tokenAddress, setTokenAddress] = useState<string>("");
|
||||
const [token, setToken] = useState<
|
||||
| {
|
||||
address: string;
|
||||
chainId: number;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
return (
|
||||
<BadgeContainer label={props.label}>
|
||||
<TokenSelector
|
||||
tokenAddress={tokenAddress}
|
||||
addNativeTokenIfMissing={false}
|
||||
showCheck={false}
|
||||
selectedToken={token}
|
||||
chainId={props.selectedChainId}
|
||||
client={storybookThirdwebClient}
|
||||
onChange={setTokenAddress}
|
||||
onChange={(v) => {
|
||||
setToken({
|
||||
address: v.address,
|
||||
chainId: v.chainId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
getAddress,
|
||||
} from "thirdweb";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
import { useTokensData } from "../../../hooks/tokens/tokens";
|
||||
import { replaceIpfsUrl } from "../../../lib/sdk";
|
||||
import { fallbackChainIcon } from "../../../utils/chain-icons";
|
||||
import type { TokenMetadata } from "../../api/universal-bridge/tokens";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Img } from "./Img";
|
||||
@@ -11,37 +17,87 @@ import { SelectWithSearch } from "./select-with-search";
|
||||
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
export function TokenSelector(props: {
|
||||
tokenAddress: string | undefined;
|
||||
onChange: (tokenAddress: string) => void;
|
||||
selectedToken: { chainId: number; address: string } | undefined;
|
||||
onChange: (token: TokenMetadata) => void;
|
||||
className?: string;
|
||||
popoverContentClassName?: string;
|
||||
chainId?: number;
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
disableChainId?: boolean;
|
||||
disableAddress?: boolean;
|
||||
align?: "center" | "start" | "end";
|
||||
placeholder?: string;
|
||||
client: ThirdwebClient;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
showCheck: boolean;
|
||||
addNativeTokenIfMissing: boolean;
|
||||
}) {
|
||||
const { tokens, isFetching } = useTokensData({
|
||||
const tokensQuery = useTokensData({
|
||||
chainId: props.chainId,
|
||||
enabled: props.enabled,
|
||||
});
|
||||
|
||||
const { idToChain } = useAllChainsData();
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
if (!tokensQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.addNativeTokenIfMissing) {
|
||||
const hasNativeToken = tokensQuery.data.some(
|
||||
(token) => token.address === checksummedNativeTokenAddress,
|
||||
);
|
||||
|
||||
if (!hasNativeToken && props.chainId) {
|
||||
return [
|
||||
{
|
||||
name:
|
||||
idToChain.get(props.chainId)?.nativeCurrency.name ??
|
||||
"Native Token",
|
||||
symbol:
|
||||
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
|
||||
decimals: 18,
|
||||
chainId: props.chainId,
|
||||
address: checksummedNativeTokenAddress,
|
||||
} satisfies TokenMetadata,
|
||||
...tokensQuery.data,
|
||||
];
|
||||
}
|
||||
}
|
||||
return tokensQuery.data;
|
||||
}, [
|
||||
tokensQuery.data,
|
||||
props.chainId,
|
||||
props.addNativeTokenIfMissing,
|
||||
idToChain,
|
||||
]);
|
||||
|
||||
const addressChainToToken = useMemo(() => {
|
||||
const value = new Map<string, TokenMetadata>();
|
||||
for (const token of tokens) {
|
||||
value.set(`${token.chainId}:${token.address}`, token);
|
||||
}
|
||||
return value;
|
||||
}, [tokens]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return tokens.allTokens.map((token) => {
|
||||
return {
|
||||
label: token.symbol,
|
||||
value: `${token.chainId}:${token.address}`,
|
||||
};
|
||||
});
|
||||
}, [tokens.allTokens]);
|
||||
return (
|
||||
tokens.map((token) => {
|
||||
return {
|
||||
label: token.symbol,
|
||||
value: `${token.chainId}:${token.address}`,
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
}, [tokens]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const token = tokens.addressChainToToken.get(option.value);
|
||||
const token = addressChainToToken.get(option.value);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
@@ -55,12 +111,12 @@ export function TokenSelector(props: {
|
||||
token.address.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
},
|
||||
[tokens],
|
||||
[addressChainToToken],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const token = tokens.addressChainToToken.get(option.value);
|
||||
const token = addressChainToToken.get(option.value);
|
||||
if (!token) {
|
||||
return option.label;
|
||||
}
|
||||
@@ -87,8 +143,8 @@ export function TokenSelector(props: {
|
||||
{token.symbol}
|
||||
</span>
|
||||
|
||||
{!props.disableChainId && (
|
||||
<Badge variant="outline" className="gap-2 max-sm:hidden">
|
||||
{!props.disableAddress && (
|
||||
<Badge variant="outline" className="gap-2 py-1 max-sm:hidden">
|
||||
<span className="text-muted-foreground">Address</span>
|
||||
{shortenAddress(token.address, 4)}
|
||||
</Badge>
|
||||
@@ -96,27 +152,37 @@ export function TokenSelector(props: {
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[tokens, props.disableChainId, props.client],
|
||||
[addressChainToToken, props.disableAddress, props.client],
|
||||
);
|
||||
|
||||
const selectedValue = props.selectedToken
|
||||
? `${props.selectedToken.chainId}:${props.selectedToken.address}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
searchPlaceholder="Search by name or symbol"
|
||||
value={props.tokenAddress}
|
||||
value={selectedValue}
|
||||
options={options}
|
||||
onValueChange={(tokenAddress) => {
|
||||
props.onChange(tokenAddress);
|
||||
const token = addressChainToToken.get(tokenAddress);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
props.onChange(token);
|
||||
}}
|
||||
closeOnSelect={true}
|
||||
showCheck={false}
|
||||
showCheck={props.showCheck}
|
||||
placeholder={
|
||||
isFetching ? "Loading Tokens..." : props.placeholder || "Select Token"
|
||||
tokensQuery.isPending
|
||||
? "Loading Tokens..."
|
||||
: props.placeholder || "Select Token"
|
||||
}
|
||||
overrideSearchFn={searchFn}
|
||||
renderOption={renderOption}
|
||||
className={props.className}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
disabled={isFetching || props.disabled}
|
||||
disabled={tokensQuery.isPending || props.disabled}
|
||||
side={props.side}
|
||||
align={props.align}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ArrowRightIcon, RocketIcon, StarIcon } from "lucide-react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { UpsellBannerCard } from "./UpsellBannerCard";
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-4xl flex-col gap-8 py-10">
|
||||
<BadgeContainer label="Green with icon (default)">
|
||||
<UpsellBannerCard
|
||||
title="Unlock more with thirdweb"
|
||||
description="Upgrade to increase limits and access advanced features."
|
||||
cta={{
|
||||
text: "View plans",
|
||||
icon: <ArrowRightIcon className="size-4" />,
|
||||
link: "#",
|
||||
}}
|
||||
trackingCategory="storybook"
|
||||
trackingLabel="green"
|
||||
icon={<RocketIcon className="size-5" />}
|
||||
accentColor="green"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Blue accent">
|
||||
<UpsellBannerCard
|
||||
title="Need more storage?"
|
||||
description="Add additional space to your account."
|
||||
cta={{
|
||||
text: "Upgrade storage",
|
||||
icon: <ArrowRightIcon className="size-4" />,
|
||||
link: "#",
|
||||
}}
|
||||
trackingCategory="storybook"
|
||||
trackingLabel="blue"
|
||||
icon={<StarIcon className="size-5" />}
|
||||
accentColor="blue"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Purple without leading icon">
|
||||
<UpsellBannerCard
|
||||
title="Join the beta"
|
||||
description="Get early access to experimental features."
|
||||
cta={{
|
||||
text: "Request access",
|
||||
icon: <ArrowRightIcon className="size-4" />,
|
||||
link: "#",
|
||||
}}
|
||||
trackingCategory="storybook"
|
||||
trackingLabel="purple"
|
||||
accentColor="purple"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "blocks/Banners/UpsellBannerCard",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type React from "react";
|
||||
|
||||
const ACCENT = {
|
||||
green: {
|
||||
border: "border-green-600 dark:border-green-700",
|
||||
bgFrom: "from-green-50 dark:from-green-900/20",
|
||||
blur: "bg-green-600",
|
||||
title: "text-green-900 dark:text-green-200",
|
||||
desc: "text-green-800 dark:text-green-300",
|
||||
iconBg: "bg-green-600 text-white",
|
||||
btn: "bg-green-600 text-white hover:bg-green-700",
|
||||
},
|
||||
blue: {
|
||||
border: "border-blue-600 dark:border-blue-700",
|
||||
bgFrom: "from-blue-50 dark:from-blue-900/20",
|
||||
blur: "bg-blue-600",
|
||||
title: "text-blue-900 dark:text-blue-200",
|
||||
desc: "text-blue-800 dark:text-blue-300",
|
||||
iconBg: "bg-blue-600 text-white",
|
||||
btn: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
},
|
||||
purple: {
|
||||
border: "border-purple-600 dark:border-purple-700",
|
||||
bgFrom: "from-purple-50 dark:from-purple-900/20",
|
||||
blur: "bg-purple-600",
|
||||
title: "text-purple-900 dark:text-purple-200",
|
||||
desc: "text-purple-800 dark:text-purple-300",
|
||||
iconBg: "bg-purple-600 text-white",
|
||||
btn: "bg-purple-600 text-white hover:bg-purple-700",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type UpsellBannerCardProps = {
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
cta: {
|
||||
text: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
link: string;
|
||||
};
|
||||
trackingCategory: string;
|
||||
trackingLabel: string;
|
||||
accentColor?: keyof typeof ACCENT;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function UpsellBannerCard(props: UpsellBannerCardProps) {
|
||||
const color = ACCENT[props.accentColor || "green"];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg border bg-gradient-to-r p-5",
|
||||
color.border,
|
||||
color.bgFrom,
|
||||
)}
|
||||
>
|
||||
{/* Decorative blur */}
|
||||
<div
|
||||
className={cn(
|
||||
"-right-10 -top-10 pointer-events-none absolute size-28 rounded-full opacity-20 blur-2xl",
|
||||
color.blur,
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{props.icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||
color.iconBg,
|
||||
)}
|
||||
>
|
||||
{props.icon}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold text-lg tracking-tight",
|
||||
color.title,
|
||||
)}
|
||||
>
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className={cn("mt-0.5 text-sm", color.desc)}>
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className={cn(
|
||||
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
|
||||
color.btn,
|
||||
)}
|
||||
>
|
||||
<TrackedLinkTW
|
||||
href={props.cta.link}
|
||||
category={props.trackingCategory}
|
||||
label={props.trackingLabel}
|
||||
>
|
||||
{props.cta.text}
|
||||
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Segment = {
|
||||
label: string;
|
||||
percent: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type DistributionBarChartProps = {
|
||||
segments: Segment[];
|
||||
title: string;
|
||||
};
|
||||
export function DistributionBarChart(props: DistributionBarChartProps) {
|
||||
const totalPercentage = props.segments.reduce(
|
||||
(sum, segment) => sum + segment.percent,
|
||||
0,
|
||||
);
|
||||
|
||||
const invalidTotalPercentage = totalPercentage !== 100;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">{props.title}</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"font-medium text-muted-foreground text-sm",
|
||||
invalidTotalPercentage && "text-red-500",
|
||||
)}
|
||||
>
|
||||
Total: {totalPercentage}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex h-3 overflow-hidden rounded-lg">
|
||||
{props.segments.map((segment) => {
|
||||
return (
|
||||
<div
|
||||
key={segment.label}
|
||||
className="flex h-full items-center justify-center transition-all duration-200"
|
||||
style={{
|
||||
width: `${segment.percent}%`,
|
||||
backgroundColor: segment.color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legends */}
|
||||
<div className="mt-3 flex flex-col gap-1 lg:flex-row lg:gap-6">
|
||||
{props.segments.map((segment) => {
|
||||
return (
|
||||
<div key={segment.label} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="size-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: segment.color,
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{segment.label}: {segment.percent}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MultiStepStatus } from "./multi-step-status";
|
||||
|
||||
const meta = {
|
||||
title: "Blocks/MultiStepStatus",
|
||||
component: MultiStepStatus,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container w-full max-w-md py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MultiStepStatus>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const AllStates: Story = {
|
||||
args: {
|
||||
steps: [
|
||||
{
|
||||
status: "completed",
|
||||
label: "Connect Wallet",
|
||||
retryLabel: "Failed to connect wallet",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
label: "Sign Message",
|
||||
retryLabel: "Failed to sign message",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
},
|
||||
{
|
||||
status: "error",
|
||||
label: "Approve Transaction",
|
||||
retryLabel: "Transaction approval failed",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
},
|
||||
{
|
||||
status: "idle",
|
||||
label: "Confirm Transaction",
|
||||
retryLabel: "Transaction confirmation failed",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
},
|
||||
{
|
||||
status: "idle",
|
||||
label: "Finalize",
|
||||
retryLabel: "Finalization failed",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CircleCheckIcon,
|
||||
CircleIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { DynamicHeight } from "../../ui/DynamicHeight";
|
||||
import { Spinner } from "../../ui/Spinner/Spinner";
|
||||
|
||||
export type MultiStepState = {
|
||||
status: "idle" | "pending" | "completed" | "error";
|
||||
retryLabel: string;
|
||||
label: string;
|
||||
execute: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function MultiStepStatus(props: {
|
||||
steps: MultiStepState[];
|
||||
}) {
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="space-y-4">
|
||||
{props.steps.map((step) => (
|
||||
<div key={step.label} className="flex items-start space-x-3 ">
|
||||
{step.status === "completed" ? (
|
||||
<CircleCheckIcon className="mt-0.5 size-5 flex-shrink-0 text-green-500" />
|
||||
) : step.status === "pending" ? (
|
||||
<Spinner className="mt-0.5 size-5 flex-shrink-0 text-foreground" />
|
||||
) : step.status === "error" ? (
|
||||
<AlertCircleIcon className="mt-0.5 size-5 flex-shrink-0 text-red-500" />
|
||||
) : (
|
||||
<CircleIcon className="mt-0.5 size-5 flex-shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
step.status === "pending"
|
||||
? "text-foreground"
|
||||
: step.status === "completed"
|
||||
? "text-green-500"
|
||||
: step.status === "error"
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground/70"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</p>
|
||||
|
||||
{step.status === "error" && (
|
||||
<div className="mt-1 space-y-2">
|
||||
<p className="mb-1 text-red-500 text-sm">{step.retryLabel}</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => step.execute()}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function CopyTextButton(props: {
|
||||
variant={props.variant || "outline"}
|
||||
aria-label={props.tooltip}
|
||||
className={cn(
|
||||
"flex h-auto w-auto gap-2 rounded-lg px-2.5 py-1.5 font-normal text-foreground",
|
||||
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
isCurrency?: boolean;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
|
||||
@@ -36,14 +36,6 @@ if (API_SERVER_SECRET) {
|
||||
|
||||
export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL || "";
|
||||
|
||||
if (THIRDWEB_ENGINE_URL) {
|
||||
experimental_taintUniqueValue(
|
||||
"Do not pass THIRDWEB_ENGINE_URL to the client",
|
||||
process,
|
||||
THIRDWEB_ENGINE_URL,
|
||||
);
|
||||
}
|
||||
|
||||
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN || "";
|
||||
|
||||
if (THIRDWEB_ACCESS_TOKEN) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
NEXT_PUBLIC_IPFS_GATEWAY_URL,
|
||||
} from "@/constants/public-envs";
|
||||
import {
|
||||
THIRDWEB_BRIDGE_URL,
|
||||
THIRDWEB_BUNDLER_DOMAIN,
|
||||
THIRDWEB_INAPP_WALLET_DOMAIN,
|
||||
THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
@@ -35,6 +36,7 @@ export function getConfiguredThirdwebClient(options: {
|
||||
social: THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
bundler: THIRDWEB_BUNDLER_DOMAIN,
|
||||
insight: THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
bridge: THIRDWEB_BRIDGE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,9 +39,10 @@ export function getFiltersFromSearchParams(params: {
|
||||
};
|
||||
|
||||
const defaultInterval =
|
||||
differenceInCalendarDays(range.to, range.from) > 30
|
||||
? "week"
|
||||
: ("day" as const);
|
||||
params.interval ??
|
||||
(differenceInCalendarDays(range.to, range.from) > 30
|
||||
? ("week" as const)
|
||||
: ("day" as const));
|
||||
|
||||
return {
|
||||
range,
|
||||
@@ -50,6 +51,6 @@ export function getFiltersFromSearchParams(params: {
|
||||
? ("day" as const)
|
||||
: params.interval === "week"
|
||||
? ("week" as const)
|
||||
: defaultInterval,
|
||||
: (defaultInterval as "day" | "week"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export type Account = {
|
||||
image?: string | null;
|
||||
name?: string;
|
||||
email?: string;
|
||||
advancedEnabled: boolean;
|
||||
emailConfirmedAt?: string;
|
||||
unconfirmedEmail?: string;
|
||||
emailConfirmationWalletAddress?: string;
|
||||
@@ -41,7 +40,6 @@ export type Account = {
|
||||
billing: "email" | "none";
|
||||
updates: "email" | "none";
|
||||
};
|
||||
// TODO - add image URL
|
||||
};
|
||||
|
||||
interface UpdateAccountNotificationsInput {
|
||||
@@ -53,27 +51,6 @@ interface ConfirmEmailInput {
|
||||
confirmationToken: string;
|
||||
}
|
||||
|
||||
interface UsageStorage {
|
||||
sumFileSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface UsageBillableByService {
|
||||
usage: {
|
||||
storage: UsageStorage;
|
||||
};
|
||||
limits: {
|
||||
storage: number;
|
||||
};
|
||||
rateLimits: {
|
||||
storage: number;
|
||||
rpc: number;
|
||||
};
|
||||
rateLimitedAt: {
|
||||
storage?: string;
|
||||
rpc?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface BillingProduct {
|
||||
name: string;
|
||||
id: string;
|
||||
@@ -345,13 +322,14 @@ export type RotateSecretKeyAPIReturnType = {
|
||||
};
|
||||
};
|
||||
|
||||
export async function rotateSecretKeyClient(projectId: string) {
|
||||
export async function rotateSecretKeyClient(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
pathname: "/v2/keys/rotate-secret-key",
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
}),
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -11,8 +11,8 @@ import type { EngineBackendWalletType } from "lib/engine";
|
||||
import { useState } from "react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { ResultItem } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/overview/components/transactions-table";
|
||||
import type { ResultItem } from "../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/engine/dedicated/(instance)/[engineId]/overview/components/transactions-table";
|
||||
import { engineKeys } from "../cache-keys";
|
||||
|
||||
// Engine instances
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { getClientThirdwebClient } from "../../../../../@/constants/thirdweb-client.client";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
|
||||
const title = "Universal Bridge: Swap, Bridge, and On-Ramp";
|
||||
const title = "Universal Bridge: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"Swap, bridge, and on-ramp to any EVM chain with thirdweb's Universal Bridge.";
|
||||
|
||||
@@ -35,6 +36,34 @@ export default async function RoutesPage({
|
||||
src="/assets/login/background.svg"
|
||||
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-24 z-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with Universal Bridge
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and
|
||||
chain.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://portal.thirdweb.com/pay"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
>
|
||||
Learn More
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 536 KiB |
+1
-1
@@ -47,7 +47,7 @@ export const SearchInput: React.FC = () => {
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search with anything!"
|
||||
placeholder="Search by token name or symbol"
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
|
||||
+15
-15
@@ -52,20 +52,20 @@ export async function RouteListRow({
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow linkBox className="hover:bg-accent/50">
|
||||
<TableRow linkBox className="group transition-colors hover:bg-accent/50">
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// For now we're using a normal img tag because the domain for these images is unknown
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
className="size-6 rounded-full border border-muted-foreground"
|
||||
className="size-7 rounded-full border border-border/50 shadow-sm transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{originTokenSymbol && (
|
||||
<CopyTextButton
|
||||
@@ -76,7 +76,7 @@ export async function RouteListRow({
|
||||
: originTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
className="relative z-10 text-base"
|
||||
className="relative z-10 font-medium text-base"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
@@ -85,22 +85,22 @@ export async function RouteListRow({
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{originChain.name}
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{originChain.name}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
className="size-6 rounded-full border border-muted-foreground"
|
||||
className="size-7 rounded-full border border-border/50 shadow-sm transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{destinationTokenSymbol && (
|
||||
<CopyTextButton
|
||||
@@ -111,7 +111,7 @@ export async function RouteListRow({
|
||||
: destinationTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
className="relative z-10 text-base"
|
||||
className="relative z-10 font-medium text-base"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
@@ -120,8 +120,8 @@ export async function RouteListRow({
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{destinationChain.name}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
+36
-85
@@ -7,7 +7,6 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Address } from "thirdweb";
|
||||
import { checksumAddress } from "thirdweb/utils";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "./routelist-card";
|
||||
@@ -25,18 +24,15 @@ export type SearchParams = Partial<{
|
||||
|
||||
// 120 is divisible by 2, 3, and 4 so card layout looks nice
|
||||
const DEFAULT_PAGE_SIZE = 120;
|
||||
const DEFAULT_PAGE = 1;
|
||||
|
||||
async function getRoutesToRender(params: SearchParams) {
|
||||
const filters: Partial<{
|
||||
limit: number;
|
||||
offset: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
originTextQuery?: string;
|
||||
destinationTextQuery?: string;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
@@ -45,7 +41,7 @@ async function getRoutesToRender(params: SearchParams) {
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.originChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.originTextQuery = params.query;
|
||||
filters.originQuery = params.query;
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
@@ -53,69 +49,20 @@ async function getRoutesToRender(params: SearchParams) {
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.destinationTextQuery = params.query;
|
||||
filters.destinationQuery = params.query;
|
||||
}
|
||||
}
|
||||
// Temporary, will update this after the /routes endpoint
|
||||
let routes = await getRoutes({ limit: 100_000 });
|
||||
|
||||
const totalCount = routes.length;
|
||||
|
||||
if (filters.originChainId) {
|
||||
routes = routes.filter(
|
||||
(route) => route.originToken.chainId === filters.originChainId,
|
||||
);
|
||||
}
|
||||
if (filters.originTokenAddress) {
|
||||
const originTokenAddress = filters.originTokenAddress;
|
||||
routes = routes.filter(
|
||||
(route) =>
|
||||
checksumAddress(route.originToken.address) ===
|
||||
checksumAddress(originTokenAddress),
|
||||
);
|
||||
}
|
||||
if (filters.destinationChainId) {
|
||||
routes = routes.filter(
|
||||
(route) => route.destinationToken.chainId === filters.destinationChainId,
|
||||
);
|
||||
}
|
||||
if (filters.destinationTokenAddress) {
|
||||
const destinationTokenAddress = filters.destinationTokenAddress;
|
||||
routes = routes.filter(
|
||||
(route) =>
|
||||
checksumAddress(route.destinationToken.address) ===
|
||||
checksumAddress(destinationTokenAddress),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.originTextQuery) {
|
||||
const originTextQuery = filters.originTextQuery.toLowerCase();
|
||||
routes = routes.filter((route) => {
|
||||
return (
|
||||
route.originToken.name.toLowerCase().includes(originTextQuery) ||
|
||||
route.originToken.symbol.toLowerCase().includes(originTextQuery)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.destinationTextQuery) {
|
||||
const destinationTextQuery = filters.destinationTextQuery.toLowerCase();
|
||||
routes = routes.filter((route) => {
|
||||
return (
|
||||
route.destinationToken.name
|
||||
.toLowerCase()
|
||||
.includes(destinationTextQuery) ||
|
||||
route.destinationToken.symbol
|
||||
.toLowerCase()
|
||||
.includes(destinationTextQuery)
|
||||
);
|
||||
});
|
||||
}
|
||||
const routes = await getRoutes({
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
offset: DEFAULT_PAGE_SIZE * ((params.page || 1) - 1),
|
||||
originQuery: filters.originQuery,
|
||||
destinationQuery: filters.destinationQuery,
|
||||
});
|
||||
|
||||
return {
|
||||
routesToRender: routes,
|
||||
totalCount,
|
||||
filteredCount: routes.length,
|
||||
routesToRender: routes.data,
|
||||
totalCount: routes.meta.totalCount,
|
||||
filteredCount: routes.meta.filteredCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,36 +75,37 @@ export async function RoutesData(props: {
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
// pagination
|
||||
const totalPages = Math.ceil(routesToRender.length / DEFAULT_PAGE_SIZE);
|
||||
|
||||
const activePage = Number(props.searchParams.page || DEFAULT_PAGE);
|
||||
const pageSize = DEFAULT_PAGE_SIZE;
|
||||
const startIndex = (activePage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedRoutes = routesToRender.slice(startIndex, endIndex);
|
||||
const totalPages = Math.ceil(filteredCount / DEFAULT_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{paginatedRoutes.length === 0 ? (
|
||||
{routesToRender.length === 0 ? (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<TableContainer className="overflow-hidden rounded-xl border border-border/50 bg-card/50 shadow-sm transition-all">
|
||||
<Table>
|
||||
<TableHeader className="z-0">
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
<TableRow className="border-border/50 border-b bg-muted/50">
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Origin Token
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Origin Chain
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Destination Token
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Destination Chain
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedRoutes.map((route) => (
|
||||
{routesToRender.map((route) => (
|
||||
<RouteListRow
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
originChainId={route.originToken.chainId}
|
||||
@@ -177,7 +125,7 @@ export async function RoutesData(props: {
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedRoutes.map((route) => (
|
||||
{routesToRender.map((route) => (
|
||||
<li
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
className="h-full"
|
||||
@@ -201,12 +149,15 @@ export async function RoutesData(props: {
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination totalPages={totalPages} activePage={activePage} />
|
||||
<ChainlistPagination
|
||||
totalPages={totalPages}
|
||||
activePage={Number(props.searchParams.page) || 1}
|
||||
/>
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-balance text-center text-muted-foreground text-sm">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{paginatedRoutes.length}</span>{" "}
|
||||
<span className="text-accent-foreground">{routesToRender.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Routes: Swap, Bridge, and On-Ramp";
|
||||
const title = "Universal Bridge Routes: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
@@ -43,15 +44,15 @@ export default async function RoutesPage(props: {
|
||||
|
||||
return (
|
||||
<section className="container mx-auto flex h-full flex-col px-4 py-10">
|
||||
<header className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-row items-center justify-between gap-4 lg:flex-col lg:justify-start">
|
||||
<header className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
|
||||
Routes
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-end gap-4 lg:flex-col ">
|
||||
<div className="flex w-full flex-row gap-4">
|
||||
<div className="flex flex-row items-end gap-4 lg:flex-col">
|
||||
<div className="flex w-full flex-row items-center gap-4">
|
||||
<SearchInput />
|
||||
<QueryType activeType={activeType} />
|
||||
<RouteListView activeView={activeView} />
|
||||
@@ -60,6 +61,29 @@ export default async function RoutesPage(props: {
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with Universal Bridge
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and chain.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://portal.thirdweb.com/pay"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
>
|
||||
Learn More
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10" />
|
||||
<RoutesData
|
||||
searchParams={searchParams}
|
||||
activeView={activeView}
|
||||
|
||||
@@ -2,35 +2,69 @@ import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
|
||||
import type { Address } from "thirdweb";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originQuery,
|
||||
destinationQuery,
|
||||
originChainId,
|
||||
destinationChainId,
|
||||
originTokenAddress,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
destinationChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originQuery) {
|
||||
url.searchParams.set("originQuery", originQuery);
|
||||
}
|
||||
if (destinationQuery) {
|
||||
url.searchParams.set("destinationQuery", destinationQuery);
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
url.searchParams.set("sortBy", "popularity");
|
||||
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
|
||||
const token = await getAuthToken();
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: token
|
||||
? {
|
||||
authorization: `Bearer ${token}`,
|
||||
}
|
||||
: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
next: { revalidate: 60 * 60 },
|
||||
});
|
||||
|
||||
if (!routesResponse.ok) {
|
||||
throw new Error("Failed to fetch routes");
|
||||
}
|
||||
const routes: { data: Route[] } = await routesResponse.json();
|
||||
const routes: {
|
||||
data: Route[];
|
||||
meta: { totalCount: number; filteredCount: number };
|
||||
} = await routesResponse.json();
|
||||
|
||||
return routes.data;
|
||||
return routes;
|
||||
}
|
||||
|
||||
+21
-3
@@ -32,8 +32,8 @@ function useChainStatswithRPC(_rpcUrl: string) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "eth_blockNumber",
|
||||
params: [],
|
||||
method: "eth_getBlockByNumber",
|
||||
params: ["latest", false],
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
@@ -41,9 +41,12 @@ function useChainStatswithRPC(_rpcUrl: string) {
|
||||
const json = await res.json();
|
||||
const latency = (performance.now() - startTimeStamp).toFixed(0);
|
||||
|
||||
const blockNumber = Number.parseInt(json.result.number, 16);
|
||||
const blockGasLimit = Number.parseInt(json.result.gasLimit, 16);
|
||||
return {
|
||||
latency,
|
||||
blockNumber: Number.parseInt(json.result, 16),
|
||||
blockNumber,
|
||||
blockGasLimit,
|
||||
};
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
@@ -115,6 +118,21 @@ export function ChainLiveStats(props: { rpc: string }) {
|
||||
</div>
|
||||
)}
|
||||
</PrimaryInfoItem>
|
||||
|
||||
{/* Block Gas Limit */}
|
||||
<PrimaryInfoItem title="Block Gas Limit" titleIcon={<PulseDot />}>
|
||||
{stats.isError ? (
|
||||
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
|
||||
) : stats.data ? (
|
||||
<p className="fade-in-0 animate-in">
|
||||
{stats.data.blockGasLimit ?? "N/A"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex h-[28px] w-[140px] py-1">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
</PrimaryInfoItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+2
@@ -126,8 +126,10 @@ const getClaimConditionTypeFromPhase = (
|
||||
if (!phase.snapshot) {
|
||||
return "public";
|
||||
}
|
||||
|
||||
if (phase.snapshot) {
|
||||
if (
|
||||
phase.price === "0" &&
|
||||
typeof phase.snapshot !== "string" &&
|
||||
phase.snapshot.length === 1 &&
|
||||
phase.snapshot.some(
|
||||
|
||||
+3
-1
@@ -25,7 +25,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Chain, ThirdwebClient } from "thirdweb";
|
||||
import { useAddContractToProject } from "../../../../../team/[team_slug]/[project_slug]/hooks/project-contracts";
|
||||
import { useAddContractToProject } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/hooks/project-contracts";
|
||||
|
||||
const TRACKING_CATEGORY = "add_to_dashboard_upsell";
|
||||
|
||||
@@ -147,6 +147,8 @@ function AddToProjectModalContent(props: {
|
||||
teamId: params.teamId,
|
||||
projectId: params.projectId,
|
||||
chainId: props.chainId,
|
||||
deploymentType: undefined,
|
||||
contractType: undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
+2
-12
@@ -8,6 +8,7 @@ import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { isAddress, isContractDeployed } from "thirdweb/utils";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
import { NebulaChatButton } from "../../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
|
||||
import { examplePrompts } from "../../../../../nebula-app/(app)/data/examplePrompts";
|
||||
import {
|
||||
getAuthTokenWalletAddress,
|
||||
getUserThirdwebClient,
|
||||
@@ -94,14 +95,6 @@ Users may be considering integrating the contract into their applications. Discu
|
||||
|
||||
The following is the user's message:`;
|
||||
|
||||
const examplePrompts: string[] = [
|
||||
"What does this contract do?",
|
||||
"What permissions or roles exist in this contract?",
|
||||
"Which functions are used the most?",
|
||||
"Has this contract been used recently?",
|
||||
"Who are the largest holders/users of this?",
|
||||
];
|
||||
|
||||
return (
|
||||
<ContractPageLayout
|
||||
chainMetadata={chainMetadata}
|
||||
@@ -124,10 +117,7 @@ The following is the user's message:`;
|
||||
chainIds: [chainId],
|
||||
wallet: accountAddress ?? undefined,
|
||||
}}
|
||||
examplePrompts={examplePrompts.map((prompt) => ({
|
||||
title: prompt,
|
||||
message: prompt,
|
||||
}))}
|
||||
examplePrompts={examplePrompts}
|
||||
/>
|
||||
{props.children}
|
||||
</ContractPageLayout>
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export function TokenDetailsCardUI(props: {
|
||||
</h2>
|
||||
<div className="flex flex-col gap-5 p-6 lg:flex-row">
|
||||
<Stat
|
||||
label="Total Supply"
|
||||
label="Circulating Supply"
|
||||
isPending={!tokenSupply}
|
||||
value={
|
||||
tokenSupply
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -8,6 +8,8 @@ import alephZeroBaner from "./temp-assets/AlephZeroBanner.jpg";
|
||||
import alephZeroCTA from "./temp-assets/AlephZeroCTA.jpg";
|
||||
import cotiBanner from "./temp-assets/COTI_Banner.jpg";
|
||||
import cotiCTA from "./temp-assets/COTI_CTA.jpg";
|
||||
import eduBanner from "./temp-assets/EDUBanner.jpeg";
|
||||
import eduCTA from "./temp-assets/EDUCTA.png";
|
||||
import hemiSepoliaBanner from "./temp-assets/HemiBanner.png";
|
||||
import hemiSepoliaCTA from "./temp-assets/HemiCTA.png";
|
||||
import ancient8Banner from "./temp-assets/ancient8-banner.png";
|
||||
@@ -41,6 +43,8 @@ import creatorCTA from "./temp-assets/creatorCTA.png";
|
||||
import lumiaBanner from "./temp-assets/lumiaBanner.png";
|
||||
import somniaBanner from "./temp-assets/somniaBanner.png";
|
||||
|
||||
import NeoXBanner from "./temp-assets/NeoXBanner.jpg";
|
||||
import NeoXCTA from "./temp-assets/NeoXCTA.jpg";
|
||||
import flowBanner from "./temp-assets/flowBanner.png";
|
||||
import flowCTA from "./temp-assets/flowCTA.png";
|
||||
import mantleBanner from "./temp-assets/mantle.png";
|
||||
@@ -374,6 +378,31 @@ const chainMetaRecord = {
|
||||
about:
|
||||
"Monetize your passion, your way. Discover the freedom to grow with our creator-focused platform.",
|
||||
},
|
||||
//EDUMainnet
|
||||
41923: {
|
||||
headerImgUrl: eduBanner.src,
|
||||
about:
|
||||
"Education On-Chain - A Layer 3 blockchain preparing students and learners for the future of work while advancing onchain education finance (EduFi).",
|
||||
cta: {
|
||||
backgroundImageUrl: eduCTA.src,
|
||||
title: "Education On-Chain",
|
||||
buttonLink: "https://educhain.xyz/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//EDU Testnet <Open Campus Codex>
|
||||
656476: {
|
||||
headerImgUrl: eduBanner.src,
|
||||
about:
|
||||
"Education On-Chain - A Layer 3 blockchain preparing students and learners for the future of work while advancing onchain education finance (EduFi).",
|
||||
cta: {
|
||||
backgroundImageUrl: eduCTA.src,
|
||||
title: "Education On-Chain",
|
||||
buttonLink: "https://educhain.xyz/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
|
||||
//Etherlink Testnet
|
||||
128123: {
|
||||
headerImgUrl: etherlinkBanner.src,
|
||||
@@ -545,6 +574,30 @@ const chainMetaRecord = {
|
||||
about:
|
||||
"Metal L2 is a banking-focused Layer 2 blockchain built on the Optimism Superchain, designed to enable direct on-chain fiat deposits through its connection to The Digital Banking Network—an open-source blockchain banking protocol developed by Metallicus",
|
||||
},
|
||||
//NEO X
|
||||
47763: {
|
||||
headerImgUrl: NeoXBanner.src,
|
||||
about:
|
||||
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
|
||||
cta: {
|
||||
backgroundImageUrl: NeoXCTA.src,
|
||||
title: "Learn more about Neo X",
|
||||
buttonLink: "https://x.neo.org/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//NEO X Testnet
|
||||
12227332: {
|
||||
headerImgUrl: NeoXBanner.src,
|
||||
about:
|
||||
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
|
||||
cta: {
|
||||
backgroundImageUrl: NeoXCTA.src,
|
||||
title: "Learn more about Neo X",
|
||||
buttonLink: "https://x.neo.org/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//0G-Newton-Testnet
|
||||
16600: {
|
||||
headerImgUrl: zeroGBanner.src,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { BookOpenIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
@@ -10,7 +9,8 @@ import contractsIcon from "../../../../../public/assets/support/contracts.png";
|
||||
import engineIcon from "../../../../../public/assets/support/engine.png";
|
||||
import miscIcon from "../../../../../public/assets/support/misc.svg";
|
||||
import connectIcon from "../../../../../public/assets/support/wallets.png";
|
||||
import { NebulaChatButton } from "../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
|
||||
import { getTeams } from "../../../../@/api/team";
|
||||
import { CustomChatButton } from "../../../nebula-app/(app)/components/CustomChat/CustomChatButton";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
@@ -118,25 +118,22 @@ const HELP_PRODUCTS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const siwaExamplePrompts = [
|
||||
"How do I add in-app wallet with sign in with google to my react app?",
|
||||
"How do I send a transaction in Unity?",
|
||||
"What does this contract revert error mean?",
|
||||
"I see thirdweb support id in my console log, can you help me?",
|
||||
"Here is my code, can you tell me why I'm seeing this error?",
|
||||
];
|
||||
|
||||
export default async function SupportPage() {
|
||||
const [authToken, accountAddress] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: undefined,
|
||||
});
|
||||
|
||||
const supportPromptPrefix =
|
||||
"You are a Customer Success Agent at thirdweb, assisting customers with blockchain and Web3-related issues. Use the following details to craft a professional, empathetic response: ";
|
||||
const examplePrompts = [
|
||||
"ERC20 - Transfer Amount Exceeds Allowance",
|
||||
"Replacement transaction underpriced / Replacement fee too low",
|
||||
"Nonce too low: next nonce #, tx nonce #",
|
||||
"Nonce too high",
|
||||
];
|
||||
const teams = await getTeams();
|
||||
const teamId = teams?.[0]?.id ?? undefined;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-12 pb-12">
|
||||
@@ -157,22 +154,16 @@ export default async function SupportPage() {
|
||||
team.
|
||||
</p>
|
||||
<div className="mt-6 flex w-full flex-col items-center gap-3">
|
||||
<NebulaChatButton
|
||||
<CustomChatButton
|
||||
isLoggedIn={!!accountAddress}
|
||||
networks="all"
|
||||
isFloating={false}
|
||||
pageType="support"
|
||||
label="Ask Nebula AI for support"
|
||||
client={client}
|
||||
nebulaParams={{
|
||||
messagePrefix: supportPromptPrefix,
|
||||
chainIds: [],
|
||||
wallet: accountAddress ?? undefined,
|
||||
}}
|
||||
examplePrompts={examplePrompts.map((prompt) => ({
|
||||
title: prompt,
|
||||
message: prompt,
|
||||
}))}
|
||||
label="Ask AI for support"
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
authToken={authToken || undefined}
|
||||
teamId={teamId}
|
||||
clientId={undefined}
|
||||
/>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -26,6 +26,7 @@ export function DeployedContractsPageHeader(props: {
|
||||
}}
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
type="contract"
|
||||
/>
|
||||
|
||||
<div className="container flex max-w-7xl flex-col gap-3 py-10 lg:flex-row lg:items-center lg:justify-between">
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ export function DeployViaCLIOrImportCard(props: {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 lg:p-6">
|
||||
<ImportModal
|
||||
type="contract"
|
||||
client={client}
|
||||
isOpen={importModalOpen}
|
||||
onClose={() => {
|
||||
|
||||
@@ -44,11 +44,13 @@ async function DeployedContractsPageAsync(props: {
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
authToken: props.authToken,
|
||||
deploymentType: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<ClientOnly ssr={<Loading />}>
|
||||
<ContractTable
|
||||
variant="contract"
|
||||
contracts={deployedContracts}
|
||||
pageSize={10}
|
||||
teamId={props.teamId}
|
||||
|
||||
@@ -7,21 +7,28 @@ export type ProjectContract = {
|
||||
chainId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deploymentType: string | null;
|
||||
contractType: string | null;
|
||||
};
|
||||
|
||||
export async function getProjectContracts(options: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
deploymentType: string | undefined;
|
||||
}) {
|
||||
const res = await fetch(
|
||||
const url = new URL(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/projects/${options.projectId}/contracts`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (options.deploymentType) {
|
||||
url.searchParams.set("deploymentType", options.deploymentType);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
|
||||
+2
@@ -9,11 +9,13 @@ export async function getSortedDeployedContracts(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
deploymentType: string | undefined;
|
||||
}) {
|
||||
const contracts = await getProjectContracts({
|
||||
teamId: params.teamId,
|
||||
projectId: params.projectId,
|
||||
authToken: params.authToken,
|
||||
deploymentType: params.deploymentType,
|
||||
});
|
||||
|
||||
const chainIds = Array.from(new Set(contracts.map((c) => c.chainId)));
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { useAuthorizedWallets } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { AuthorizedWalletsTable } from "components/settings/AuthorizedWallets/AuthorizedWalletsTable";
|
||||
|
||||
// TODO - remove ChakraProviderSetup after migrating AuthorizedWalletsTable
|
||||
// TODO - fetch the authorized wallets server side
|
||||
|
||||
export function AccountDevicesPage() {
|
||||
const authorizedWalletsQuery = useAuthorizedWallets();
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<AuthorizedWalletsTable
|
||||
authorizedWallets={authorizedWalletsQuery.data || []}
|
||||
isPending={authorizedWalletsQuery.isPending}
|
||||
isFetched={authorizedWalletsQuery.isFetched}
|
||||
/>
|
||||
</ChakraProviderSetup>
|
||||
<AuthorizedWalletsTable
|
||||
authorizedWallets={authorizedWalletsQuery.data || []}
|
||||
isPending={authorizedWalletsQuery.isPending}
|
||||
isFetched={authorizedWalletsQuery.isFetched}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ function TeamRow(props: {
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-semibold text-sm">{props.team.name}</p>
|
||||
<TeamPlanBadge plan={plan} />
|
||||
<TeamPlanBadge plan={plan} teamSlug={props.team.slug} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm capitalize">
|
||||
{props.role.toLowerCase()}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { useTrack } from "../../../hooks/analytics/useTrack";
|
||||
|
||||
const teamPlanToBadgeVariant: Record<
|
||||
Team["billingPlan"],
|
||||
@@ -31,11 +35,12 @@ export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
|
||||
}
|
||||
|
||||
export function TeamPlanBadge(props: {
|
||||
teamSlug: string;
|
||||
plan: Team["billingPlan"];
|
||||
className?: string;
|
||||
postfix?: string;
|
||||
}) {
|
||||
return (
|
||||
const badge = (
|
||||
<Badge
|
||||
variant={teamPlanToBadgeVariant[props.plan]}
|
||||
className={cn("px-1.5 capitalize", props.className)}
|
||||
@@ -43,4 +48,25 @@ export function TeamPlanBadge(props: {
|
||||
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const track = useTrack();
|
||||
|
||||
if (props.plan === "free") {
|
||||
return (
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true`}
|
||||
onClick={() => {
|
||||
track({
|
||||
category: "billing",
|
||||
action: "show_plans",
|
||||
label: "team_badge",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
||||
+11
-3
@@ -2,9 +2,9 @@
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { useTransition } from "react";
|
||||
import { useStripeRedirectEvent } from "../../../../(stripe)/stripe-redirect/stripeRedirectChannel";
|
||||
|
||||
@@ -54,9 +54,17 @@ function BillingAlertBanner(props: {
|
||||
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
|
||||
)}
|
||||
>
|
||||
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
|
||||
<TrackedLinkTW
|
||||
href={`/team/${props.teamSlug}/~/settings/invoices`}
|
||||
category="billingBanner"
|
||||
label={
|
||||
props.variant === "warning"
|
||||
? "pastDue_viewInvoices"
|
||||
: "serviceCutoff_payNow"
|
||||
}
|
||||
>
|
||||
{props.ctaLabel}
|
||||
</Link>
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import { UpsellBannerCard } from "@/components/blocks/UpsellBannerCard";
|
||||
import { ArrowRightIcon, RocketIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Banner shown to teams on the free plan encouraging them to upgrade.
|
||||
* It links to the team's billing settings page and automatically opens
|
||||
* the pricing modal via the `showPlans=true` query param.
|
||||
*/
|
||||
export function FreePlanUpsellBannerUI(props: {
|
||||
teamSlug: string;
|
||||
highlightPlan: Team["billingPlan"];
|
||||
}) {
|
||||
return (
|
||||
<UpsellBannerCard
|
||||
title="Unlock more with thirdweb"
|
||||
description="Upgrade to increase limits and access advanced features."
|
||||
cta={{
|
||||
text: "View plans",
|
||||
icon: <ArrowRightIcon className="size-4" />,
|
||||
link: `/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${
|
||||
props.highlightPlan || "growth"
|
||||
}`,
|
||||
}}
|
||||
trackingCategory="billingBanner"
|
||||
trackingLabel="freePlan_viewPlans"
|
||||
icon={<RocketIcon className="size-5" />}
|
||||
accentColor="green"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -118,9 +118,9 @@ export async function TotalSponsoredChartCardUI({
|
||||
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.filter((d) => (d[key] as number) > 0).length >= 2
|
||||
? ((data[data.length - 2]?.[key] as number) ?? 0) /
|
||||
((data[data.length - 3]?.[key] as number) ?? 0) -
|
||||
((data[0]?.[key] as number) ?? 0) -
|
||||
1
|
||||
: undefined
|
||||
}
|
||||
|
||||
+2
-2
@@ -119,9 +119,9 @@ export async function TransactionsChartCardUI({
|
||||
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.filter((d) => (d[key] as number) > 0).length >= 2
|
||||
? ((data[data.length - 2]?.[key] as number) ?? 0) /
|
||||
((data[data.length - 3]?.[key] as number) ?? 0) -
|
||||
((data[0]?.[key] as number) ?? 0) -
|
||||
1
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { AnnouncementBanner } from "components/notices/AnnouncementBanner";
|
||||
import { redirect } from "next/navigation";
|
||||
import { siwaExamplePrompts } from "../../../(dashboard)/support/page";
|
||||
import { getClientThirdwebClient } from "../../../../../@/constants/thirdweb-client.client";
|
||||
import { CustomChatButton } from "../../../../nebula-app/(app)/components/CustomChat/CustomChatButton";
|
||||
import { getValidAccount } from "../../../account/settings/getAccount";
|
||||
import {
|
||||
getAuthToken,
|
||||
@@ -91,6 +93,19 @@ export default async function TeamLayout(props: {
|
||||
</div>
|
||||
|
||||
<main className="flex grow flex-col">{props.children}</main>
|
||||
<div className="fixed right-6 bottom-6 z-50">
|
||||
<CustomChatButton
|
||||
clientId={undefined}
|
||||
isLoggedIn={true}
|
||||
networks="all"
|
||||
isFloating={true}
|
||||
pageType="support"
|
||||
label="Ask AI Assistant"
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
teamId={team.id}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { Changelog } from "./_components/Changelog";
|
||||
import { FreePlanUpsellBannerUI } from "./_components/FreePlanUpsellBannerUI";
|
||||
import { InviteTeamMembersButton } from "./_components/invite-team-members-button";
|
||||
import {
|
||||
type ProjectWithAnalytics,
|
||||
@@ -55,11 +56,18 @@ export default async function Page(props: {
|
||||
<div className="container flex grow flex-col gap-4 lg:flex-row">
|
||||
{/* left */}
|
||||
<div className="flex grow flex-col gap-6 pt-8 lg:pb-20">
|
||||
<DismissibleAlert
|
||||
title="Looking for Engines?"
|
||||
description="Engines, contracts, project settings, and more are now managed within projects. Open or create a project to access them."
|
||||
localStorageId={`${team.id}-engines-alert`}
|
||||
/>
|
||||
{team.billingPlan === "free" ? (
|
||||
<FreePlanUpsellBannerUI
|
||||
teamSlug={team.slug}
|
||||
highlightPlan="growth"
|
||||
/>
|
||||
) : (
|
||||
<DismissibleAlert
|
||||
title="Looking for Engines?"
|
||||
description="Engines, contracts, project settings, and more are now managed within projects. Open or create a project to access them."
|
||||
localStorageId={`${team.id}-engines-alert`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TeamProjectsPage
|
||||
projects={projectsWithTotalWallets}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getClientTransactions,
|
||||
getInAppWalletUsage,
|
||||
getUniversalBridgeUsage,
|
||||
getUserOpUsage,
|
||||
getWalletConnections,
|
||||
getWalletUsers,
|
||||
@@ -14,6 +15,7 @@ import { redirect } from "next/navigation";
|
||||
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UniversalBridgeStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "types/analytics";
|
||||
@@ -24,7 +26,10 @@ import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
|
||||
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import {
|
||||
EmptyStateCard,
|
||||
EmptyStateContent,
|
||||
} from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { Suspense } from "react";
|
||||
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
|
||||
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
|
||||
@@ -100,6 +105,7 @@ async function OverviewPageContent(props: {
|
||||
userOpUsage,
|
||||
clientTransactionsTimeSeries,
|
||||
clientTransactions,
|
||||
universalBridgeUsage,
|
||||
] = await Promise.all([
|
||||
// Aggregated wallet connections
|
||||
getWalletConnections({
|
||||
@@ -148,6 +154,13 @@ async function OverviewPageContent(props: {
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
// Universal Bridge
|
||||
getUniversalBridgeUsage({
|
||||
teamId: teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isEmpty =
|
||||
@@ -164,8 +177,9 @@ async function OverviewPageContent(props: {
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
|
||||
<div className="">
|
||||
<UsersChartCard
|
||||
<AppHighlightsCard
|
||||
userStats={walletUserStatsTimeSeries}
|
||||
volumeStats={universalBridgeUsage}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
@@ -218,80 +232,108 @@ async function OverviewPageContent(props: {
|
||||
);
|
||||
}
|
||||
|
||||
type UserMetrics = {
|
||||
totalUsers: number;
|
||||
type AggregatedMetrics = {
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
returningUsers: number;
|
||||
totalVolume: number;
|
||||
feesCollected: number;
|
||||
};
|
||||
|
||||
type TimeSeriesMetrics = UserMetrics & {
|
||||
type TimeSeriesMetrics = AggregatedMetrics & {
|
||||
date: string;
|
||||
};
|
||||
|
||||
function processTimeSeriesData(
|
||||
userStats: WalletUserStats[],
|
||||
volumeStats: UniversalBridgeStats[],
|
||||
): TimeSeriesMetrics[] {
|
||||
const metrics: TimeSeriesMetrics[] = [];
|
||||
|
||||
let cumulativeUsers = 0;
|
||||
for (const stat of userStats) {
|
||||
cumulativeUsers += stat.newUsers ?? 0;
|
||||
const volume = volumeStats
|
||||
.filter((v) => v.date === stat.date && v.status === "completed")
|
||||
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
|
||||
|
||||
const fees = volumeStats
|
||||
.filter((v) => v.date === stat.date && v.status === "completed")
|
||||
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
|
||||
|
||||
metrics.push({
|
||||
date: stat.date,
|
||||
activeUsers: stat.totalUsers ?? 0,
|
||||
returningUsers: stat.returningUsers ?? 0,
|
||||
newUsers: stat.newUsers ?? 0,
|
||||
totalUsers: cumulativeUsers,
|
||||
totalVolume: volume,
|
||||
feesCollected: fees,
|
||||
});
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function UsersChartCard({
|
||||
function AppHighlightsCard({
|
||||
userStats,
|
||||
volumeStats,
|
||||
searchParams,
|
||||
}: {
|
||||
userStats: WalletUserStats[];
|
||||
volumeStats: UniversalBridgeStats[];
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const timeSeriesData = processTimeSeriesData(userStats);
|
||||
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
|
||||
|
||||
const chartConfig = {
|
||||
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
|
||||
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" },
|
||||
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
|
||||
returningUsers: {
|
||||
label: "Returning Users",
|
||||
color: "hsl(var(--chart-4))",
|
||||
totalVolume: {
|
||||
label: "Total Volume",
|
||||
color: "hsl(var(--chart-2))",
|
||||
isCurrency: true,
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
metric="Payments"
|
||||
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
|
||||
link="https://portal.thirdweb.com/connect/pay/overview"
|
||||
/>
|
||||
),
|
||||
},
|
||||
feesCollected: {
|
||||
label: "Fee Revenue",
|
||||
color: "hsl(var(--chart-4))",
|
||||
isCurrency: true,
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
metric="Fees"
|
||||
description="Your apps haven't collected any fees yet."
|
||||
link={"https://portal.thirdweb.com/connect/pay/fees"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
|
||||
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
|
||||
title="Users"
|
||||
title="App Highlights"
|
||||
chartConfig={chartConfig}
|
||||
activeChart={
|
||||
(searchParams?.usersChart as keyof UserMetrics) ?? "activeUsers"
|
||||
(searchParams?.appHighlights as keyof AggregatedMetrics) ??
|
||||
"totalVolume"
|
||||
}
|
||||
data={timeSeriesData}
|
||||
aggregateFn={(_data, key) =>
|
||||
// If there is only one data point, use that one, otherwise use the previous
|
||||
timeSeriesData.filter((d) => (d[key] as number) > 0).length >= 2
|
||||
? timeSeriesData[timeSeriesData.length - 2]?.[key]
|
||||
: timeSeriesData[timeSeriesData.length - 1]?.[key]
|
||||
}
|
||||
// Get the trend from the last two COMPLETE periods
|
||||
aggregateFn={(_data, key) => {
|
||||
if (key === "activeUsers") {
|
||||
return Math.max(...timeSeriesData.map((d) => d[key]));
|
||||
}
|
||||
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
|
||||
}}
|
||||
trendFn={(data, key) =>
|
||||
data.filter((d) => (d[key] as number) > 0).length >= 3
|
||||
data.filter((d) => (d[key] as number) > 0).length >= 2
|
||||
? ((data[data.length - 2]?.[key] as number) ?? 0) /
|
||||
((data[data.length - 3]?.[key] as number) ?? 0) -
|
||||
((data[0]?.[key] as number) ?? 0) -
|
||||
1
|
||||
: undefined
|
||||
}
|
||||
queryKey="usersChart"
|
||||
queryKey="appHighlights"
|
||||
existingQueryParams={searchParams}
|
||||
/>
|
||||
);
|
||||
|
||||
+10
@@ -1,3 +1,4 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken";
|
||||
@@ -25,6 +26,13 @@ export async function EcosystemLayoutSlug({
|
||||
params.team_slug,
|
||||
);
|
||||
|
||||
// Fetch team details to obtain team ID for further authenticated updates
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
if (!ecosystem) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
@@ -35,6 +43,8 @@ export async function EcosystemLayoutSlug({
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={ecosystemLayoutPath}
|
||||
teamIdOrSlug={params.team_slug}
|
||||
authToken={authToken}
|
||||
teamId={team.id}
|
||||
/>
|
||||
|
||||
<SidebarLayout
|
||||
|
||||
+213
-6
@@ -1,9 +1,19 @@
|
||||
"use client";
|
||||
/* eslint-disable */
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,19 +22,28 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ImageUpload } from "@/components/ui/image-upload";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDashboardStorageUpload } from "@3rdweb-sdk/react/hooks/useDashboardStorageUpload";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
ExternalLinkIcon,
|
||||
PencilIcon,
|
||||
PlusCircleIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useEcosystemList } from "../../../hooks/use-ecosystem-list";
|
||||
import type { Ecosystem } from "../../../types";
|
||||
import { useUpdateEcosystem } from "../configuration/hooks/use-update-ecosystem";
|
||||
import { useEcosystem } from "../hooks/use-ecosystem";
|
||||
|
||||
function EcosystemAlertBanner({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
@@ -113,6 +132,8 @@ export function EcosystemHeader(props: {
|
||||
ecosystem: Ecosystem;
|
||||
ecosystemLayoutPath: string;
|
||||
teamIdOrSlug: string;
|
||||
authToken: string;
|
||||
teamId: string;
|
||||
}) {
|
||||
const { data: fetchedEcosystem } = useEcosystem({
|
||||
teamIdOrSlug: props.teamIdOrSlug,
|
||||
@@ -135,6 +156,83 @@ export function EcosystemHeader(props: {
|
||||
client,
|
||||
});
|
||||
|
||||
// ------------------- Image Upload Logic -------------------
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
// Name editing state
|
||||
const [isNameDialogOpen, setIsNameDialogOpen] = useState(false);
|
||||
const [tempName, setTempName] = useState(ecosystem.name);
|
||||
|
||||
const storageUpload = useDashboardStorageUpload();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const { mutateAsync: updateEcosystem, isPending: isUpdating } =
|
||||
useUpdateEcosystem(
|
||||
{
|
||||
authToken: props.authToken,
|
||||
teamId: props.teamId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Ecosystem updated");
|
||||
setIsDialogOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to update image";
|
||||
toast.error(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isUploading = storageUpload.isPending || isUpdating;
|
||||
|
||||
async function handleUpload() {
|
||||
if (!selectedFile) {
|
||||
toast.error("Please select an image to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(selectedFile.type)) {
|
||||
toast.error("Only PNG, JPG or WEBP images are allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [uri] = await storageUpload.mutateAsync([selectedFile]);
|
||||
await updateEcosystem({
|
||||
...ecosystem,
|
||||
imageUrl: uri,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to upload image");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNameSave() {
|
||||
const trimmed = tempName.trim();
|
||||
if (!trimmed || trimmed === ecosystem.name) {
|
||||
setIsNameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateEcosystem({
|
||||
...ecosystem,
|
||||
name: trimmed,
|
||||
});
|
||||
setIsNameDialogOpen(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to update name");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b py-8">
|
||||
<div className="container flex flex-col gap-8">
|
||||
@@ -146,11 +244,74 @@ export function EcosystemHeader(props: {
|
||||
<Skeleton className="size-24" />
|
||||
) : (
|
||||
ecosystemImageLink && (
|
||||
<Img
|
||||
src={ecosystemImageLink}
|
||||
alt={ecosystem.name}
|
||||
className="size-24 rounded-full border object-contain object-center"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Img
|
||||
src={ecosystemImageLink}
|
||||
alt={ecosystem.name}
|
||||
className={cn(
|
||||
"size-24",
|
||||
"border",
|
||||
"rounded-full",
|
||||
"object-contain object-center",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Upload Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"absolute",
|
||||
"right-0 bottom-0",
|
||||
"h-6 w-6",
|
||||
"p-1",
|
||||
"rounded-full",
|
||||
"bg-background",
|
||||
"hover:bg-accent",
|
||||
)}
|
||||
aria-label="Change logo"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Ecosystem Logo</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<ImageUpload
|
||||
onUpload={(files) => {
|
||||
if (files?.[0]) {
|
||||
setSelectedFile(files[0]);
|
||||
}
|
||||
}}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={isUploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || !selectedFile}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
"Upload"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -159,6 +320,52 @@ export function EcosystemHeader(props: {
|
||||
) : (
|
||||
<h2 className="font-semibold text-3xl text-foreground tracking-tight">
|
||||
{ecosystem.name}
|
||||
<Dialog
|
||||
open={isNameDialogOpen}
|
||||
onOpenChange={setIsNameDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2 h-5 w-5 rounded-full p-1 hover:bg-accent"
|
||||
aria-label="Edit name"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Ecosystem Name</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<Input
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={isUpdating}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleNameSave}
|
||||
disabled={isUpdating || !tempName.trim()}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</h2>
|
||||
)}
|
||||
{!ecosystem.slug ? (
|
||||
@@ -169,7 +376,7 @@ export function EcosystemHeader(props: {
|
||||
textToCopy={`ecosystem.${ecosystem.slug}`}
|
||||
textToShow={`ecosystem.${ecosystem.slug}`}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Ecosytem slug"
|
||||
tooltip="Copy Ecosystem slug"
|
||||
variant="ghost"
|
||||
className="-translate-x-2 px-2 py-0.5 text-muted-foreground"
|
||||
/>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { UsageBillableByService } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
|
||||
export async function getAccountUsage() {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account/usage`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as UsageBillableByService;
|
||||
}
|
||||
+7
-15
@@ -4,10 +4,7 @@ import type { TeamSubscription } from "@/api/team-subscription";
|
||||
import type { RPCUsageDataItem } from "@/api/usage/rpc";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
Account,
|
||||
UsageBillableByService,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { InAppWalletUsersChartCardUI } from "components/embedded-wallets/Analytics/InAppWalletUsersChartCard";
|
||||
import { subMonths } from "date-fns";
|
||||
import Link from "next/link";
|
||||
@@ -18,8 +15,6 @@ import { SponsoredTransactionsTable } from "./SponsoredTransactionsTable";
|
||||
import { UsageCard } from "./UsageCard";
|
||||
|
||||
type UsageProps = {
|
||||
// TODO - remove when we have all the data available from team
|
||||
usage: UsageBillableByService;
|
||||
subscriptions: TeamSubscription[];
|
||||
account: Account;
|
||||
team: Team;
|
||||
@@ -39,7 +34,6 @@ const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
});
|
||||
|
||||
export const Usage: React.FC<UsageProps> = ({
|
||||
usage: usageData,
|
||||
subscriptions,
|
||||
account,
|
||||
team,
|
||||
@@ -48,20 +42,18 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
rpcUsage,
|
||||
}) => {
|
||||
const gatewayMetrics = useMemo(() => {
|
||||
if (!usageData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Unlimited Requests",
|
||||
total: (
|
||||
<span className="text-muted-foreground">
|
||||
{compactNumberFormatter.format(usageData.rateLimits.storage)} Requests
|
||||
Per Second
|
||||
{compactNumberFormatter.format(
|
||||
team.capabilities.storage.upload.rateLimit,
|
||||
)}{" "}
|
||||
Requests Per Second
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}, [usageData]);
|
||||
}, [team.capabilities.storage.upload.rateLimit]);
|
||||
|
||||
const usageSub = subscriptions.find((sub) => sub.type === "USAGE");
|
||||
|
||||
@@ -138,7 +130,7 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
<ThirdwebBarChart
|
||||
header={{
|
||||
title: "RPC Requests",
|
||||
description: `Your plan allows for ${usageData.rateLimits.rpc} requests per second`,
|
||||
description: `Your plan allows for ${team.capabilities.rpc.rateLimit} requests per second`,
|
||||
titleClassName: "text-xl mb-0.5",
|
||||
}}
|
||||
data={rpcUsageData}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getClientThirdwebClient } from "../../../../../../../@/constants/thirdw
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { getAccountUsage } from "./getAccountUsage";
|
||||
import { Usage } from "./overview/components/Usage";
|
||||
|
||||
export default async function Page(props: {
|
||||
@@ -31,8 +30,7 @@ export default async function Page(props: {
|
||||
loginRedirect(`/team/${params.team_slug}/~/usage`);
|
||||
}
|
||||
|
||||
const [accountUsage, subscriptions, projects, rpcUsage] = await Promise.all([
|
||||
getAccountUsage(),
|
||||
const [subscriptions, projects, rpcUsage] = await Promise.all([
|
||||
getTeamSubscriptions(team.slug),
|
||||
getProjects(team.slug),
|
||||
fetchRPCUsage({
|
||||
@@ -48,7 +46,7 @@ export default async function Page(props: {
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!accountUsage || !subscriptions) {
|
||||
if (!subscriptions) {
|
||||
return (
|
||||
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
|
||||
Something went wrong. Please try again later.
|
||||
@@ -63,8 +61,6 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<Usage
|
||||
// TODO - remove accountUsage when we have all the data available from team
|
||||
usage={accountUsage}
|
||||
subscriptions={subscriptions}
|
||||
account={account}
|
||||
team={team}
|
||||
|
||||
@@ -86,7 +86,7 @@ export default async function RPCUsage(props: {
|
||||
<div className="font-bold text-2xl capitalize">
|
||||
{currentRateLimit.toLocaleString()} RPS
|
||||
</div>
|
||||
<TeamPlanBadge plan={currentPlan} />
|
||||
<TeamPlanBadge plan={currentPlan} teamSlug={team.slug} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImportModal } from "components/contract-components/import-contract/modal";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { ArrowDownToLineIcon, CoinsIcon, ImagesIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
|
||||
export function Cards(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
client: ThirdwebClient;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<ImportModal
|
||||
client={props.client}
|
||||
isOpen={importModalOpen}
|
||||
onClose={() => {
|
||||
setImportModalOpen(false);
|
||||
}}
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
type="asset"
|
||||
/>
|
||||
|
||||
<CardLink
|
||||
title="Create Token"
|
||||
description="Launch your own ERC-20 token"
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/assets/create`}
|
||||
icon={CoinsIcon}
|
||||
trackingLabel="create-token"
|
||||
/>
|
||||
|
||||
<CardLink
|
||||
title="Create NFT Collection"
|
||||
description="Launch your own ERC-721 NFT collection"
|
||||
href={undefined}
|
||||
icon={ImagesIcon}
|
||||
trackingLabel="create-nft"
|
||||
badge="Coming Soon"
|
||||
/>
|
||||
|
||||
<CardLink
|
||||
title="Import Existing Asset"
|
||||
description="Import tokens or NFTs you own to the project"
|
||||
href={undefined}
|
||||
icon={ArrowDownToLineIcon}
|
||||
trackingLabel="import-asset"
|
||||
onClick={() => {
|
||||
setImportModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardLink(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string | undefined;
|
||||
onClick?: () => void;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
trackingLabel: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
const { onClick } = props;
|
||||
const isClickable = !!onClick || !!props.href;
|
||||
const trackEvent = useTrack();
|
||||
|
||||
function handleClick() {
|
||||
trackEvent({
|
||||
category: "assets-landing-page",
|
||||
action: "click",
|
||||
label: props.trackingLabel,
|
||||
});
|
||||
onClick?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border bg-card p-4",
|
||||
isClickable && "cursor-pointer hover:border-active-border ",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex">
|
||||
<div className="flex items-center justify-center rounded-full border p-2.5">
|
||||
<props.icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.badge && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-dashed py-1 text-muted-foreground"
|
||||
>
|
||||
{props.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
|
||||
{props.href ? (
|
||||
<Link href={props.href} className="before:absolute before:inset-0">
|
||||
{props.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{props.title}</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">{props.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { getStepCardTrackingData } from "./tracking";
|
||||
|
||||
export function StepCard(props: {
|
||||
title: string;
|
||||
page: "info" | "distribution" | "launch";
|
||||
prevButton:
|
||||
| undefined
|
||||
| {
|
||||
onClick: () => void;
|
||||
};
|
||||
nextButton:
|
||||
| undefined
|
||||
| {
|
||||
type: "submit";
|
||||
}
|
||||
| {
|
||||
type: "custom";
|
||||
custom: React.ReactNode;
|
||||
};
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<h2 className="border-b px-4 py-5 font-semibold text-xl tracking-tight md:px-6">
|
||||
{props.title}
|
||||
</h2>
|
||||
|
||||
{props.children}
|
||||
|
||||
<div className="flex justify-end gap-3 border-t p-6">
|
||||
{props.prevButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
props.prevButton?.onClick();
|
||||
trackEvent(
|
||||
getStepCardTrackingData({
|
||||
step: props.page,
|
||||
click: "prev",
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{props.nextButton && props.nextButton.type === "submit" && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-2"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
trackEvent(
|
||||
getStepCardTrackingData({
|
||||
step: props.page,
|
||||
click: "next",
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{props.nextButton &&
|
||||
props.nextButton.type === "custom" &&
|
||||
props.nextButton.custom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
import { revalidatePathAction } from "@/actions/revalidate";
|
||||
import {
|
||||
DEFAULT_FEE_BPS_NEW,
|
||||
DEFAULT_FEE_RECIPIENT,
|
||||
} from "constants/addresses";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { defineDashboardChain } from "lib/defineDashboardChain";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
getAddress,
|
||||
getContract,
|
||||
sendAndConfirmTransaction,
|
||||
toUnits,
|
||||
} from "thirdweb";
|
||||
import { deployERC20Contract } from "thirdweb/deploys";
|
||||
import type { ClaimConditionsInput } from "thirdweb/dist/types/utils/extensions/drops/types";
|
||||
import {
|
||||
claimTo,
|
||||
setClaimConditions as setClaimConditionsExtension,
|
||||
transferBatch,
|
||||
} from "thirdweb/extensions/erc20";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { useAddContractToProject } from "../../hooks/project-contracts";
|
||||
import { CreateTokenAssetPageUI } from "./create-token-page.client";
|
||||
import type { CreateAssetFormValues } from "./form";
|
||||
import {
|
||||
getTokenDeploymentTrackingData,
|
||||
getTokenStepTrackingData,
|
||||
} from "./tracking";
|
||||
|
||||
export function CreateTokenAssetPage(props: {
|
||||
accountAddress: string;
|
||||
client: ThirdwebClient;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const account = useActiveAccount();
|
||||
const { idToChain } = useAllChainsData();
|
||||
const addContractToProject = useAddContractToProject();
|
||||
const contractAddressRef = useRef<string | undefined>(undefined);
|
||||
const trackEvent = useTrack();
|
||||
|
||||
async function deployContract(formValues: CreateAssetFormValues) {
|
||||
if (!account) {
|
||||
toast.error("No Connected Wallet");
|
||||
throw new Error("No Connected Wallet");
|
||||
}
|
||||
|
||||
trackEvent(
|
||||
getTokenDeploymentTrackingData("attempt", Number(formValues.chain)),
|
||||
);
|
||||
|
||||
const socialUrls = formValues.socialUrls.reduce(
|
||||
(acc, url) => {
|
||||
if (url.url && url.platform) {
|
||||
acc[url.platform] = url.url;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
try {
|
||||
const contractAddress = await deployERC20Contract({
|
||||
account,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
),
|
||||
client: props.client,
|
||||
type: "DropERC20",
|
||||
params: {
|
||||
// metadata
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
symbol: formValues.symbol,
|
||||
image: formValues.image,
|
||||
// platform fees
|
||||
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
|
||||
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
|
||||
// primary sale
|
||||
saleRecipient: account.address,
|
||||
social_urls: socialUrls,
|
||||
},
|
||||
});
|
||||
|
||||
trackEvent(
|
||||
getTokenDeploymentTrackingData("success", Number(formValues.chain)),
|
||||
);
|
||||
|
||||
// add contract to project in background
|
||||
addContractToProject.mutateAsync({
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
contractAddress: contractAddress,
|
||||
chainId: formValues.chain,
|
||||
deploymentType: "asset",
|
||||
contractType: "DropERC20",
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
|
||||
return {
|
||||
contractAddress: contractAddress,
|
||||
};
|
||||
} catch (e) {
|
||||
trackEvent(
|
||||
getTokenDeploymentTrackingData("error", Number(formValues.chain)),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function airdropTokens(formValues: CreateAssetFormValues) {
|
||||
const contractAddress = contractAddressRef.current;
|
||||
|
||||
if (!contractAddress) {
|
||||
throw new Error("No contract address");
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
throw new Error("No connected account");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
);
|
||||
|
||||
const contract = getContract({
|
||||
client: props.client,
|
||||
address: contractAddress,
|
||||
chain,
|
||||
});
|
||||
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "airdrop",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "attempt",
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const airdropTx = transferBatch({
|
||||
contract,
|
||||
batch: formValues.airdropAddresses.map((recipient) => ({
|
||||
to: recipient.address,
|
||||
amount: recipient.quantity,
|
||||
})),
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: airdropTx,
|
||||
account,
|
||||
});
|
||||
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "airdrop",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "success",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "airdrop",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "error",
|
||||
}),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function mintTokens(formValues: CreateAssetFormValues) {
|
||||
const contractAddress = contractAddressRef.current;
|
||||
if (!contractAddress) {
|
||||
throw new Error("No contract address");
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
throw new Error("No connected account");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
);
|
||||
|
||||
const contract = getContract({
|
||||
client: props.client,
|
||||
address: contractAddress,
|
||||
chain,
|
||||
});
|
||||
|
||||
const totalSupply = Number(formValues.supply);
|
||||
const salePercent = formValues.saleEnabled
|
||||
? Number(formValues.saleAllocationPercentage)
|
||||
: 0;
|
||||
|
||||
const ownerAndAirdropPercent = 100 - salePercent;
|
||||
const ownerSupplyTokens = (totalSupply * ownerAndAirdropPercent) / 100;
|
||||
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "mint",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "attempt",
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const claimTx = claimTo({
|
||||
contract,
|
||||
to: account.address,
|
||||
quantity: ownerSupplyTokens.toString(),
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: claimTx,
|
||||
account,
|
||||
});
|
||||
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "mint",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "success",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "mint",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "error",
|
||||
}),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function setClaimConditions(formValues: CreateAssetFormValues) {
|
||||
const contractAddress = contractAddressRef.current;
|
||||
|
||||
if (!contractAddress) {
|
||||
throw new Error("No contract address");
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
throw new Error("No connected account");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
);
|
||||
|
||||
const contract = getContract({
|
||||
client: props.client,
|
||||
address: contractAddress,
|
||||
chain,
|
||||
});
|
||||
|
||||
const salePercent = formValues.saleEnabled
|
||||
? Number(formValues.saleAllocationPercentage)
|
||||
: 0;
|
||||
|
||||
const totalSupply = Number(formValues.supply);
|
||||
const totalSupplyWei = toUnits(totalSupply.toString(), 18);
|
||||
|
||||
const phases: ClaimConditionsInput[] = [
|
||||
{
|
||||
maxClaimablePerWallet: formValues.saleEnabled ? undefined : 0n,
|
||||
maxClaimableSupply: totalSupplyWei,
|
||||
price:
|
||||
formValues.saleEnabled && salePercent > 0
|
||||
? formValues.salePrice
|
||||
: "0",
|
||||
currencyAddress:
|
||||
getAddress(formValues.saleTokenAddress) ===
|
||||
getAddress(NATIVE_TOKEN_ADDRESS)
|
||||
? undefined
|
||||
: formValues.saleTokenAddress,
|
||||
startTime: new Date(),
|
||||
metadata: {
|
||||
name:
|
||||
formValues.saleEnabled && salePercent > 0
|
||||
? "Token Sale phase"
|
||||
: "Only Owner phase",
|
||||
},
|
||||
overrideList: [
|
||||
{
|
||||
address: account.address,
|
||||
maxClaimable: "unlimited",
|
||||
price: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const preparedTx = setClaimConditionsExtension({
|
||||
contract,
|
||||
phases,
|
||||
});
|
||||
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "claim-conditions",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "attempt",
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: preparedTx,
|
||||
account,
|
||||
});
|
||||
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "claim-conditions",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "success",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
trackEvent(
|
||||
getTokenStepTrackingData({
|
||||
action: "claim-conditions",
|
||||
chainId: Number(formValues.chain),
|
||||
status: "error",
|
||||
}),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateTokenAssetPageUI
|
||||
accountAddress={props.accountAddress}
|
||||
client={props.client}
|
||||
onLaunchSuccess={() => {
|
||||
revalidatePathAction(
|
||||
`/team/${props.teamId}/project/${props.projectId}/assets`,
|
||||
"page",
|
||||
);
|
||||
}}
|
||||
createTokenFunctions={{
|
||||
deployContract,
|
||||
airdropTokens,
|
||||
mintTokens,
|
||||
setClaimConditions,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
getAddress,
|
||||
} from "thirdweb";
|
||||
import { TokenDistributionFieldset } from "./distribution/token-distribution";
|
||||
import {
|
||||
type CreateAssetFormValues,
|
||||
type TokenDistributionFormValues,
|
||||
type TokenInfoFormValues,
|
||||
tokenDistributionFormSchema,
|
||||
tokenInfoFormSchema,
|
||||
} from "./form";
|
||||
import { LaunchTokenStatus } from "./launch/launch-token";
|
||||
import { TokenInfoFieldset } from "./token-info-fieldset";
|
||||
|
||||
export type CreateTokenFunctions = {
|
||||
deployContract: (values: CreateAssetFormValues) => Promise<{
|
||||
contractAddress: string;
|
||||
}>;
|
||||
setClaimConditions: (values: CreateAssetFormValues) => Promise<void>;
|
||||
mintTokens: (values: CreateAssetFormValues) => Promise<void>;
|
||||
airdropTokens: (values: CreateAssetFormValues) => Promise<void>;
|
||||
};
|
||||
|
||||
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
export function CreateTokenAssetPageUI(props: {
|
||||
accountAddress: string;
|
||||
client: ThirdwebClient;
|
||||
createTokenFunctions: CreateTokenFunctions;
|
||||
onLaunchSuccess: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
|
||||
"token-info",
|
||||
);
|
||||
|
||||
const tokenInfoForm = useForm<TokenInfoFormValues>({
|
||||
resolver: zodResolver(tokenInfoFormSchema),
|
||||
values: {
|
||||
name: "",
|
||||
description: "",
|
||||
symbol: "",
|
||||
image: undefined,
|
||||
chain: "1",
|
||||
socialUrls: [
|
||||
{
|
||||
platform: "Website",
|
||||
url: "",
|
||||
},
|
||||
{
|
||||
platform: "Twitter",
|
||||
url: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const tokenDistributionForm = useForm<TokenDistributionFormValues>({
|
||||
resolver: zodResolver(tokenDistributionFormSchema),
|
||||
values: {
|
||||
// sale fieldset
|
||||
saleAllocationPercentage: "0",
|
||||
saleTokenAddress: checksummedNativeTokenAddress,
|
||||
salePrice: "0.1",
|
||||
supply: "1000000",
|
||||
saleEnabled: false,
|
||||
// airdrop
|
||||
airdropEnabled: false,
|
||||
airdropAddresses: [],
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{step === "token-info" && (
|
||||
<TokenInfoFieldset
|
||||
onChainUpdated={() => {
|
||||
// if the chain is updated, set the sale token address to the native token address
|
||||
tokenDistributionForm.setValue(
|
||||
"saleTokenAddress",
|
||||
checksummedNativeTokenAddress,
|
||||
);
|
||||
}}
|
||||
client={props.client}
|
||||
form={tokenInfoForm}
|
||||
onNext={() => {
|
||||
setStep("distribution");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "distribution" && (
|
||||
<TokenDistributionFieldset
|
||||
client={props.client}
|
||||
form={tokenDistributionForm}
|
||||
accountAddress={props.accountAddress}
|
||||
chainId={tokenInfoForm.watch("chain")}
|
||||
onPrevious={() => {
|
||||
setStep("token-info");
|
||||
}}
|
||||
onNext={() => {
|
||||
setStep("launch");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "launch" && (
|
||||
<LaunchTokenStatus
|
||||
client={props.client}
|
||||
onLaunchSuccess={props.onLaunchSuccess}
|
||||
onPrevious={() => {
|
||||
setStep("distribution");
|
||||
}}
|
||||
createTokenFunctions={props.createTokenFunctions}
|
||||
values={{
|
||||
...tokenInfoForm.getValues(),
|
||||
...tokenDistributionForm.getValues(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { storybookThirdwebClient } from "stories/utils";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { CreateTokenAssetPageUI } from "./create-token-page.client";
|
||||
|
||||
const meta = {
|
||||
title: "Assets/CreateTokenPage",
|
||||
component: CreateTokenAssetPageUI,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThirdwebProvider>
|
||||
<div className="container max-w-5xl py-10">
|
||||
<ConnectButton client={storybookThirdwebClient} />
|
||||
<div className="h-10" />
|
||||
<Story />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof CreateTokenAssetPageUI>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const mockCreateTokenFunctions = {
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { contractAddress: "0x123" };
|
||||
},
|
||||
setClaimConditions: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
mintTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
airdropTokens: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
accountAddress: "0x1234567890123456789012345678901234567890",
|
||||
client: storybookThirdwebClient,
|
||||
createTokenFunctions: mockCreateTokenFunctions,
|
||||
onLaunchSuccess: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorOnDeploy: Story = {
|
||||
args: {
|
||||
accountAddress: "0x1234567890123456789012345678901234567890",
|
||||
client: storybookThirdwebClient,
|
||||
onLaunchSuccess: () => {},
|
||||
createTokenFunctions: {
|
||||
...mockCreateTokenFunctions,
|
||||
deployContract: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("Failed to deploy contract");
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
"use client";
|
||||
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCsvUpload } from "hooks/useCsvUpload";
|
||||
import {
|
||||
ArrowDownToLineIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
CircleAlertIcon,
|
||||
FileTextIcon,
|
||||
RotateCcwIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { TokenDistributionForm } from "../form";
|
||||
|
||||
type AirdropAddressInput = {
|
||||
address: string;
|
||||
quantity: string;
|
||||
isValid?: boolean;
|
||||
resolvedAddress?: string;
|
||||
};
|
||||
|
||||
export function TokenAirdropSection(props: {
|
||||
form: TokenDistributionForm;
|
||||
}) {
|
||||
const airdropAddresses = props.form.watch("airdropAddresses");
|
||||
const [showUploadSheet, setShowUploadSheet] = useState(false);
|
||||
const totalAirdropSupply = airdropAddresses.reduce(
|
||||
(acc, curr) => acc + Number(curr.quantity),
|
||||
0,
|
||||
);
|
||||
|
||||
const isEnabled = props.form.watch("airdropEnabled");
|
||||
|
||||
const removeAirdropAddresses = () => {
|
||||
props.form.setValue("airdropAddresses", []);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="relative border-t border-dashed">
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-5">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Airdrop</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Airdrop tokens to a list of addresses with each address receiving
|
||||
a specific quantity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
props.form.setValue("airdropEnabled", checked);
|
||||
if (!checked) {
|
||||
removeAirdropAddresses();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEnabled && (
|
||||
<div className="flex justify-start px-6 pb-6">
|
||||
{airdropAddresses.length > 0 ? (
|
||||
<div className="flex w-full flex-col gap-4 rounded-lg border bg-background p-4 md:flex-row lg:items-center lg:justify-between">
|
||||
{/* left */}
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">CSV File Uploaded</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<span className="font-semibold">
|
||||
{airdropAddresses.length}
|
||||
</span>{" "}
|
||||
addresses will receive a total of{" "}
|
||||
<span className="font-semibold">{totalAirdropSupply}</span>{" "}
|
||||
tokens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* right */}
|
||||
<div className="flex gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<FileTextIcon className="mr-2 size-4" />
|
||||
View CSV
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent className="flex h-dvh w-full flex-col gap-0 overflow-hidden lg:max-w-2xl">
|
||||
<SheetHeader className="mb-3">
|
||||
<SheetTitle className="text-left">
|
||||
Airdrop CSV
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AirdropTable
|
||||
data={airdropAddresses}
|
||||
className="rounded-b-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-3 rounded-b-lg border border-t-0 bg-card p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={removeAirdropAddresses}
|
||||
>
|
||||
<XIcon className="mr-2 size-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={removeAirdropAddresses}
|
||||
className="gap-2"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Sheet open={showUploadSheet} onOpenChange={setShowUploadSheet}>
|
||||
<SheetContent className="flex h-dvh w-full flex-col gap-0 overflow-hidden lg:max-w-2xl">
|
||||
<SheetHeader className="mb-3">
|
||||
<SheetTitle className="text-left font-semibold text-lg">
|
||||
Airdrop CSV File
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Upload a CSV file to airdrop tokens to a list of
|
||||
addresses
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<AirdropUpload
|
||||
setAirdrop={(addresses) => {
|
||||
props.form.setValue("airdropAddresses", addresses);
|
||||
setShowUploadSheet(false);
|
||||
}}
|
||||
onClose={() => setShowUploadSheet(false)}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowUploadSheet(true)}
|
||||
className="min-w-44 gap-2 bg-background"
|
||||
>
|
||||
<ArrowUpFromLineIcon className="size-4 text-muted-foreground" />
|
||||
Upload CSV
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
|
||||
type AirdropUploadProps = {
|
||||
setAirdrop: (airdrop: AirdropAddressInput[]) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// CSV parser for airdrop data
|
||||
const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => {
|
||||
return items
|
||||
.map(({ address, quantity }) => ({
|
||||
address: (address || "").trim(),
|
||||
quantity: (quantity || "1").trim(),
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
};
|
||||
|
||||
const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
setAirdrop,
|
||||
onClose,
|
||||
}) => {
|
||||
const {
|
||||
normalizeQuery,
|
||||
getInputProps,
|
||||
getRootProps,
|
||||
rawData,
|
||||
noCsv,
|
||||
reset,
|
||||
removeInvalid,
|
||||
} = useCsvUpload<AirdropAddressInput>({ csvParser });
|
||||
|
||||
const normalizeData = normalizeQuery.data;
|
||||
|
||||
if (!normalizeData) {
|
||||
return (
|
||||
<div className="flex h-[300px] w-full grow items-center justify-center rounded-lg border border-border">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
setAirdrop(
|
||||
normalizeData.result.map((o) => ({
|
||||
address: o.resolvedAddress || o.address,
|
||||
quantity: o.quantity,
|
||||
isValid: o.isValid,
|
||||
})),
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full grow flex-col gap-6 overflow-hidden">
|
||||
{normalizeData.result.length && rawData.length > 0 ? (
|
||||
<div className="flex grow flex-col overflow-hidden outline">
|
||||
{normalizeQuery.data.invalidFound && (
|
||||
<p className="mb-3 text-red-500 text-sm">
|
||||
Invalid addresses found. Please remove them before continuing.
|
||||
</p>
|
||||
)}
|
||||
<AirdropTable
|
||||
data={normalizeData.result}
|
||||
className="rounded-b-none"
|
||||
/>
|
||||
<div className="flex justify-between gap-3 rounded-b-lg border border-t-0 bg-card p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={rawData.length === 0}
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<RotateCcwIcon className="mr-2 size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{normalizeQuery.data.invalidFound ? (
|
||||
<Button
|
||||
disabled={rawData.length === 0}
|
||||
onClick={() => {
|
||||
removeInvalid();
|
||||
}}
|
||||
>
|
||||
<XIcon className="mr-2 size-4" />
|
||||
Remove invalid addresses
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleContinue} disabled={rawData.length === 0}>
|
||||
Continue <ArrowRightIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[300px] cursor-pointer items-center justify-center rounded-md border border-dashed bg-card hover:border-active-border",
|
||||
noCsv &&
|
||||
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} accept=".csv" />
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
{!noCsv && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
|
||||
<UploadIcon className="size-5" />
|
||||
</div>
|
||||
<h2 className="mb-0.5 text-center font-medium text-lg">
|
||||
Upload CSV File
|
||||
</h2>
|
||||
<p className="text-center font-medium text-muted-foreground text-sm">
|
||||
Drag and drop your file or click here to upload
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noCsv && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
|
||||
<XIcon className="size-5" />
|
||||
</div>
|
||||
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
|
||||
Invalid CSV
|
||||
</h2>
|
||||
<p className="text-balance text-center text-sm">
|
||||
Your CSV does not contain the "address" & "quantity"
|
||||
columns
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="relative z-50 mt-4"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
Remove Invalid CSV
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6" />
|
||||
<CSVFormatDetails />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function CSVFormatDetails() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold">CSV Format</h3>
|
||||
<ul className="mb-3 list-none space-y-2 text-muted-foreground text-sm">
|
||||
<li>
|
||||
CSV file must contain <InlineCode code="address" /> and
|
||||
<InlineCode code="quantity" /> columns. If the quantity value is not
|
||||
provided in a record, it will default to 1 token.
|
||||
</li>
|
||||
<li>
|
||||
Repeated addresses will be removed and only the first found will be
|
||||
kept.
|
||||
</li>
|
||||
<li>
|
||||
ENS Name can be used as an address as well, Address will automatically
|
||||
be resolved
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<DownloadExampleCSVButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadExampleCSVButton() {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const link = document.createElement("a");
|
||||
const exampleData = [
|
||||
{
|
||||
address: "0x000000000000000000000000000000000000dEaD",
|
||||
quantity: "2",
|
||||
},
|
||||
{
|
||||
address: "thirdweb.eth",
|
||||
quantity: "1",
|
||||
},
|
||||
];
|
||||
|
||||
const csv = `address,quantity\n${exampleData
|
||||
.map((o) => `${o.address},${o.quantity}`)
|
||||
.join("\n")}`;
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = "airdrop.csv";
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
<ArrowDownToLineIcon className="mr-2 size-4" />
|
||||
Download Example CSV
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AirdropTable(props: {
|
||||
data: AirdropAddressInput[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TableContainer
|
||||
className={cn("flex flex-1 flex-col overflow-hidden", props.className)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Quantity</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.data.map((item, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<TableRow key={index} className="!border-b">
|
||||
<TableCell>
|
||||
{item.isValid ? (
|
||||
item.address
|
||||
) : (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-4 text-red-500" />
|
||||
<span className="font-bold text-red-500">
|
||||
{item.address}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import {
|
||||
DistributionBarChart,
|
||||
type Segment,
|
||||
} from "@/components/blocks/distribution-chart";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { StepCard } from "../create-token-card";
|
||||
import type {
|
||||
TokenDistributionForm,
|
||||
TokenDistributionFormValues,
|
||||
} from "../form";
|
||||
import { TokenAirdropSection } from "./token-airdrop";
|
||||
import { TokenSaleSection } from "./token-sale";
|
||||
|
||||
export function TokenDistributionFieldset(props: {
|
||||
accountAddress: string;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
form: TokenDistributionForm;
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { form } = props;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(props.onNext)}>
|
||||
<StepCard
|
||||
title="Token Distribution"
|
||||
page="distribution"
|
||||
prevButton={{
|
||||
onClick: props.onPrevious,
|
||||
}}
|
||||
nextButton={{
|
||||
type: "submit",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="space-y-6 p-4 md:px-6 md:py-6">
|
||||
<FormFieldSetup
|
||||
label="Total Supply"
|
||||
isRequired
|
||||
htmlFor="supply"
|
||||
errorMessage={form.formState.errors.supply?.message}
|
||||
>
|
||||
<div className="relative">
|
||||
<Input id="supply" {...form.register("supply")} />
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
|
||||
Tokens
|
||||
</span>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
<TokenDistributionBarChart
|
||||
distributionFormValues={form.watch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TokenAirdropSection form={form} />
|
||||
<TokenSaleSection
|
||||
form={form}
|
||||
chainId={props.chainId}
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
</StepCard>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function TokenDistributionBarChart(props: {
|
||||
distributionFormValues: TokenDistributionFormValues;
|
||||
}) {
|
||||
const totalSupply = Number(props.distributionFormValues.supply);
|
||||
const totalAirdropSupply =
|
||||
props.distributionFormValues.airdropAddresses.reduce(
|
||||
(acc, curr) => acc + Number(curr.quantity),
|
||||
0,
|
||||
);
|
||||
const airdropPercentage = (totalAirdropSupply / totalSupply) * 100;
|
||||
const salePercentage = Number(
|
||||
props.distributionFormValues.saleAllocationPercentage,
|
||||
);
|
||||
const ownerPercentage = 100 - airdropPercentage - salePercentage;
|
||||
|
||||
const tokenAllocations: Segment[] = [
|
||||
{
|
||||
label: "Owner",
|
||||
percent: ownerPercentage,
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
{
|
||||
label: "Airdrop",
|
||||
percent: airdropPercentage,
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
{
|
||||
label: "Sale",
|
||||
percent: salePercentage,
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DistributionBarChart
|
||||
segments={tokenAllocations}
|
||||
title="Token Allocation"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { TokenSelector } from "@/components/blocks/TokenSelector";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { TokenDistributionForm } from "../form";
|
||||
|
||||
export function TokenSaleSection(props: {
|
||||
form: TokenDistributionForm;
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const totalSupply = Number(props.form.watch("supply"));
|
||||
const sellSupply = Math.floor(
|
||||
(totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100,
|
||||
);
|
||||
|
||||
const isEnabled = props.form.watch("saleEnabled");
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="relative border-t border-dashed p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Sale</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Make your token available for purchase by setting a price
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
props.form.setValue("saleEnabled", checked);
|
||||
if (!checked) {
|
||||
props.form.setValue("saleAllocationPercentage", "0");
|
||||
props.form.setValue("salePrice", "0");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEnabled && (
|
||||
<div className="mt-4 flex flex-col gap-6">
|
||||
<FormFieldSetup
|
||||
label="Sell % of Total Supply"
|
||||
isRequired
|
||||
helperText={`${sellSupply} tokens`}
|
||||
errorMessage={
|
||||
props.form.formState.errors.saleAllocationPercentage?.message
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
maxValue={100}
|
||||
value={props.form.watch("saleAllocationPercentage")}
|
||||
onChange={(value) => {
|
||||
props.form.setValue("saleAllocationPercentage", value);
|
||||
}}
|
||||
/>
|
||||
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<FormFieldSetup
|
||||
label="Price per Token"
|
||||
isRequired
|
||||
errorMessage={props.form.formState.errors.salePrice?.message}
|
||||
>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
value={props.form.watch("salePrice")}
|
||||
onChange={(value) => {
|
||||
props.form.setValue("salePrice", value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
label="Currency"
|
||||
isRequired
|
||||
errorMessage={
|
||||
props.form.formState.errors.saleTokenAddress?.message
|
||||
}
|
||||
>
|
||||
<TokenSelector
|
||||
className="bg-background"
|
||||
addNativeTokenIfMissing={true}
|
||||
showCheck={true}
|
||||
disableAddress={true}
|
||||
selectedToken={{
|
||||
address: props.form.watch("saleTokenAddress"),
|
||||
chainId: Number(props.chainId),
|
||||
}}
|
||||
onChange={(value) => {
|
||||
props.form.setValue("saleTokenAddress", value.address);
|
||||
}}
|
||||
client={props.client}
|
||||
chainId={Number(props.chainId)}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
|
||||
function DecimalInput(props: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
maxValue?: number;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={props.value}
|
||||
inputMode="decimal"
|
||||
onChange={(e) => {
|
||||
const number = Number(e.target.value);
|
||||
// ignore if string becomes invalid number
|
||||
if (Number.isNaN(number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.maxValue && number > props.maxValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// replace leading multiple zeros with single zero
|
||||
let cleanedValue = e.target.value.replace(/^0+/, "0");
|
||||
|
||||
// replace leading zero before decimal point
|
||||
if (!cleanedValue.includes(".")) {
|
||||
cleanedValue = cleanedValue.replace(/^0+/, "");
|
||||
}
|
||||
|
||||
props.onChange(cleanedValue || "0");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { isAddress } from "thirdweb";
|
||||
import * as z from "zod";
|
||||
|
||||
const addressSchema = z.string().refine(
|
||||
(value) => {
|
||||
if (isAddress(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message: "Invalid address",
|
||||
},
|
||||
);
|
||||
|
||||
const urlSchema = z.string().url();
|
||||
|
||||
export const tokenInfoFormSchema = z.object({
|
||||
// info fieldset
|
||||
name: z.string().min(1, "Name is required"),
|
||||
symbol: z
|
||||
.string()
|
||||
.min(1, "Symbol is required")
|
||||
.max(10, "Symbol must be 10 characters or less"),
|
||||
chain: z.string().min(1, "Chain is required"),
|
||||
description: z.string().optional(),
|
||||
image: z.any().optional(),
|
||||
socialUrls: z.array(
|
||||
z.object({
|
||||
platform: z.string(),
|
||||
url: z.string().refine(
|
||||
(val) => {
|
||||
if (val === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const url = val.startsWith("http") ? val : `https://${val}`;
|
||||
return urlSchema.safeParse(url);
|
||||
},
|
||||
{
|
||||
message: "Invalid URL",
|
||||
},
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const tokenDistributionFormSchema = z.object({
|
||||
supply: z.string().min(1, "Supply is required"),
|
||||
saleAllocationPercentage: z.string().refine(
|
||||
(value) => {
|
||||
const number = Number(value);
|
||||
if (Number.isNaN(number)) {
|
||||
return false;
|
||||
}
|
||||
return number >= 0 && number <= 100;
|
||||
},
|
||||
{
|
||||
message: "Must be a number between 0 and 100",
|
||||
},
|
||||
),
|
||||
saleTokenAddress: z.string(),
|
||||
salePrice: z.string().refine(
|
||||
(value) => {
|
||||
const number = Number(value);
|
||||
return !Number.isNaN(number) && number >= 0;
|
||||
},
|
||||
{
|
||||
message: "Must be number larger than or equal to 0",
|
||||
},
|
||||
),
|
||||
airdropAddresses: z.array(
|
||||
z.object({
|
||||
address: addressSchema,
|
||||
quantity: z.string(),
|
||||
}),
|
||||
),
|
||||
// UI states
|
||||
airdropEnabled: z.boolean(),
|
||||
saleEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type TokenDistributionForm = UseFormReturn<
|
||||
z.infer<typeof tokenDistributionFormSchema>
|
||||
>;
|
||||
export type TokenDistributionFormValues = z.infer<
|
||||
typeof tokenDistributionFormSchema
|
||||
>;
|
||||
|
||||
export type TokenInfoFormValues = z.infer<typeof tokenInfoFormSchema>;
|
||||
export type TokenInfoForm = UseFormReturn<TokenInfoFormValues>;
|
||||
|
||||
export type CreateAssetFormValues = TokenInfoFormValues &
|
||||
TokenDistributionFormValues;
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import {
|
||||
type MultiStepState,
|
||||
MultiStepStatus,
|
||||
} from "@/components/blocks/multi-step-status/multi-step-status";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { ArrowRightIcon, ImageOffIcon, RocketIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet } from "thirdweb/react";
|
||||
import { StepCard } from "../create-token-card";
|
||||
import type { CreateTokenFunctions } from "../create-token-page.client";
|
||||
import { TokenDistributionBarChart } from "../distribution/token-distribution";
|
||||
import type { CreateAssetFormValues } from "../form";
|
||||
import { getLaunchTrackingData } from "../tracking";
|
||||
|
||||
export function LaunchTokenStatus(props: {
|
||||
createTokenFunctions: CreateTokenFunctions;
|
||||
values: CreateAssetFormValues;
|
||||
onPrevious: () => void;
|
||||
client: ThirdwebClient;
|
||||
onLaunchSuccess: () => void;
|
||||
}) {
|
||||
const formValues = props.values;
|
||||
const { createTokenFunctions } = props;
|
||||
const [steps, setSteps] = useState<MultiStepState[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [contractLink, setContractLink] = useState<string | null>(null);
|
||||
const activeWallet = useActiveWallet();
|
||||
const walletRequiresApproval = activeWallet?.id !== "inApp";
|
||||
const trackEvent = useTrack();
|
||||
|
||||
async function handleSubmitClick() {
|
||||
function launchTracking(type: "attempt" | "success" | "error") {
|
||||
trackEvent(
|
||||
getLaunchTrackingData({
|
||||
chainId: Number(formValues.chain),
|
||||
airdropEnabled: formValues.airdropEnabled,
|
||||
saleEnabled: formValues.saleEnabled,
|
||||
type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
launchTracking("attempt");
|
||||
|
||||
function updateStatus(index: number, newStatus: MultiStepState["status"]) {
|
||||
setSteps((prev) => {
|
||||
return [
|
||||
...prev.slice(0, index),
|
||||
{ ...prev[index], status: newStatus },
|
||||
...prev.slice(index + 1),
|
||||
] as MultiStepState[];
|
||||
});
|
||||
}
|
||||
|
||||
function createSequenceExecutorFn(
|
||||
index: number,
|
||||
executeFn: (values: CreateAssetFormValues) => Promise<void>,
|
||||
) {
|
||||
return async () => {
|
||||
updateStatus(index, "pending");
|
||||
try {
|
||||
await executeFn(formValues);
|
||||
updateStatus(index, "completed");
|
||||
// start next one
|
||||
const nextStep = initialSteps[index + 1];
|
||||
if (nextStep) {
|
||||
// do not use await next step
|
||||
nextStep.execute();
|
||||
} else {
|
||||
launchTracking("success");
|
||||
props.onLaunchSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus(index, "error");
|
||||
launchTracking("error");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const initialSteps: MultiStepState[] = [
|
||||
{
|
||||
label: "Deploy contract",
|
||||
status: "idle",
|
||||
retryLabel: "Failed to deploy contract",
|
||||
execute: createSequenceExecutorFn(0, async (values) => {
|
||||
const result = await createTokenFunctions.deployContract(values);
|
||||
setContractLink(`/${values.chain}/${result.contractAddress}`);
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Set claim conditions",
|
||||
status: "idle",
|
||||
retryLabel: "Failed to set claim conditions",
|
||||
execute: createSequenceExecutorFn(
|
||||
1,
|
||||
createTokenFunctions.setClaimConditions,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Mint tokens",
|
||||
status: "idle",
|
||||
retryLabel: "Failed to mint tokens",
|
||||
execute: createSequenceExecutorFn(2, createTokenFunctions.mintTokens),
|
||||
},
|
||||
];
|
||||
|
||||
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
|
||||
initialSteps.push({
|
||||
label: "Airdrop tokens",
|
||||
status: "idle",
|
||||
retryLabel: "Failed to airdrop tokens",
|
||||
execute: createSequenceExecutorFn(
|
||||
3,
|
||||
createTokenFunctions.airdropTokens,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
setSteps(initialSteps);
|
||||
setIsModalOpen(true);
|
||||
|
||||
// start sequence
|
||||
initialSteps[0]?.execute();
|
||||
}
|
||||
|
||||
const isComplete = steps.every((step) => step.status === "completed");
|
||||
const isPending = steps.some((step) => step.status === "pending");
|
||||
|
||||
return (
|
||||
<StepCard
|
||||
page="launch"
|
||||
title="Launch Token"
|
||||
prevButton={{
|
||||
onClick: props.onPrevious,
|
||||
}}
|
||||
nextButton={{
|
||||
type: "custom",
|
||||
custom: (
|
||||
<TransactionButton
|
||||
variant="default"
|
||||
txChainID={Number(formValues.chain)}
|
||||
isLoggedIn={true}
|
||||
isPending={false}
|
||||
transactionCount={undefined}
|
||||
onClick={handleSubmitClick}
|
||||
>
|
||||
<RocketIcon className="size-4" />
|
||||
Launch Token
|
||||
</TransactionButton>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{/* Token info */}
|
||||
<div className="flex flex-col gap-6 border-b border-dashed px-4 py-6 pb-6 md:px-6 lg:flex-row">
|
||||
<OverviewField name="Image" className="shrink-0">
|
||||
<RenderFileImage file={formValues.image} />
|
||||
</OverviewField>
|
||||
|
||||
<div className="flex grow flex-col gap-4 ">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6 lg:border-b lg:border-dashed lg:pb-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
|
||||
<OverviewField name="Name">
|
||||
<OverviewFieldValue value={formValues.name} />
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField name="Symbol">
|
||||
<OverviewFieldValue value={formValues.symbol} />
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField name="Chain">
|
||||
<ChainOverview client={props.client} chainId={formValues.chain} />
|
||||
</OverviewField>
|
||||
</div>
|
||||
|
||||
<OverviewField name="Description">
|
||||
<OverviewFieldValue
|
||||
value={formValues.description || "No Description"}
|
||||
/>
|
||||
</OverviewField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token distribution */}
|
||||
<div className="flex flex-col gap-4 px-4 py-6 md:px-6">
|
||||
<OverviewField name="Total Supply">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{compactNumberFormatter.format(Number(formValues.supply))}
|
||||
</p>
|
||||
</OverviewField>
|
||||
|
||||
<TokenDistributionBarChart distributionFormValues={formValues} />
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
// do not set onOpenChange
|
||||
>
|
||||
<DialogContent
|
||||
className="gap-0 overflow-hidden p-0 md:max-w-[480px]"
|
||||
dialogCloseClassName="hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
</DialogTitle>
|
||||
{walletRequiresApproval && (
|
||||
<DialogDescription>
|
||||
Each step will prompt a signature request in your wallet
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<MultiStepStatus steps={steps} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between gap-4 border-border border-t bg-card p-6">
|
||||
{isComplete && contractLink ? (
|
||||
<div>
|
||||
<Button asChild className="gap-2">
|
||||
<Link href={contractLink}>
|
||||
View Launched Token <ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
setIsModalOpen(false);
|
||||
// reset steps
|
||||
setSteps([]);
|
||||
}}
|
||||
>
|
||||
{isComplete ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 10,
|
||||
});
|
||||
|
||||
function RenderFileImage(props: {
|
||||
file: File | undefined;
|
||||
}) {
|
||||
const [objectUrl, setObjectUrl] = useState<string>("");
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (props.file) {
|
||||
const url = URL.createObjectURL(props.file);
|
||||
setObjectUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setObjectUrl("");
|
||||
}
|
||||
}, [props.file]);
|
||||
|
||||
return (
|
||||
<Img
|
||||
src={objectUrl}
|
||||
className="size-24 rounded-lg border object-cover"
|
||||
fallback={
|
||||
<div className="flex items-center justify-center bg-muted/50">
|
||||
<ImageOffIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewField(props: {
|
||||
name: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<p className="mb-1 text-muted-foreground text-sm">{props.name}</p>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewFieldValue(props: {
|
||||
value: string;
|
||||
}) {
|
||||
return <p className="text-foreground text-sm">{props.value}</p>;
|
||||
}
|
||||
|
||||
function ChainOverview(props: {
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chainMetadata = idToChain.get(Number(props.chainId));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-3.5"
|
||||
client={props.client}
|
||||
src={chainMetadata?.icon?.url || ""}
|
||||
/>
|
||||
<p className="text-foreground text-sm">
|
||||
{chainMetadata?.name || `Chain ${props.chainId}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../login/loginRedirect";
|
||||
import { CreateTokenAssetPage } from "./create-token-page-impl";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const [authToken, team, project, accountAddress] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
if (!authToken || !accountAddress) {
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/assets/create`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<PageHeader
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
/>
|
||||
<div className="container max-w-5xl pt-8 pb-32">
|
||||
<CreateTokenAssetPage
|
||||
accountAddress={accountAddress}
|
||||
client={client}
|
||||
teamId={team.id}
|
||||
projectId={project.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="border-b py-3">
|
||||
<Breadcrumb className="container max-w-5xl">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/assets`}
|
||||
>
|
||||
Assets
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Create Token</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="container flex max-w-5xl flex-col gap-3 py-8 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Create Token
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Launch an ERC-20 token for your project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ClientOnly } from "components/ClientOnly/ClientOnly";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { StepCard } from "./create-token-card";
|
||||
import type { TokenInfoForm } from "./form";
|
||||
|
||||
export function TokenInfoFieldset(props: {
|
||||
client: ThirdwebClient;
|
||||
onNext: () => void;
|
||||
form: TokenInfoForm;
|
||||
onChainUpdated: () => void;
|
||||
}) {
|
||||
const { form } = props;
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(props.onNext)}>
|
||||
<StepCard
|
||||
page="info"
|
||||
title="Token Information"
|
||||
prevButton={undefined}
|
||||
nextButton={{
|
||||
type: "submit",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-6 px-4 py-6 md:grid md:grid-cols-[280px_1fr] md:px-6">
|
||||
{/* left */}
|
||||
<FormFieldSetup
|
||||
errorMessage={form.formState.errors.image?.message as string}
|
||||
label="Image"
|
||||
isRequired={false}
|
||||
>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
value={form.watch("image")}
|
||||
setValue={(file) =>
|
||||
form.setValue("image", file, {
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
className="rounded-lg border-border bg-background transition-all duration-200 hover:border-active-border hover:bg-background"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
{/* right */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* name + symbol */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-4">
|
||||
<FormFieldSetup
|
||||
className="grow"
|
||||
label="Name"
|
||||
isRequired
|
||||
htmlFor="name"
|
||||
errorMessage={form.formState.errors.name?.message}
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="My Token"
|
||||
{...form.register("name")}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
className="lg:max-w-[200px]"
|
||||
label="Symbol"
|
||||
isRequired
|
||||
htmlFor="symbol"
|
||||
errorMessage={form.formState.errors.symbol?.message}
|
||||
>
|
||||
<Input
|
||||
id="symbol"
|
||||
placeholder="XYZ"
|
||||
{...form.register("symbol")}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
|
||||
{/* chain */}
|
||||
<FormFieldSetup
|
||||
label="Chain"
|
||||
isRequired
|
||||
htmlFor="chain"
|
||||
errorMessage={form.formState.errors.chain?.message}
|
||||
>
|
||||
<ClientOnly ssr={null}>
|
||||
<SingleNetworkSelector
|
||||
className="bg-background"
|
||||
client={props.client}
|
||||
chainId={Number(form.watch("chain"))}
|
||||
onChange={(chain) => {
|
||||
form.setValue("chain", chain.toString());
|
||||
props.onChainUpdated();
|
||||
}}
|
||||
disableChainId
|
||||
/>
|
||||
</ClientOnly>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
label="Description"
|
||||
isRequired={false}
|
||||
htmlFor="description"
|
||||
className="flex grow flex-col"
|
||||
errorMessage={form.formState.errors.description?.message}
|
||||
>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Describe your token"
|
||||
className="grow"
|
||||
{...form.register("description")}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SocialUrls form={form} />
|
||||
</StepCard>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialUrls(props: {
|
||||
form: TokenInfoForm;
|
||||
}) {
|
||||
const { form } = props;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: "socialUrls",
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-t border-dashed px-4 py-6 lg:px-6">
|
||||
<h2 className="mb-2 font-medium text-sm">Social URLs</h2>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="mb-5 space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`socialUrls.${index}.platform`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="lg:max-w-[140px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Platform"
|
||||
aria-label="Platform"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`socialUrls.${index}.url`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://..."
|
||||
aria-label="Platform URL"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => append({ platform: "", url: "" })}
|
||||
className="gap-2"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Social URL
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
export function getTokenDeploymentTrackingData(
|
||||
type: "attempt" | "success" | "error",
|
||||
chainId: number,
|
||||
) {
|
||||
// using "custom-contract" because it has to match the main deployment tracking format
|
||||
return {
|
||||
category: "custom-contract",
|
||||
action: "deploy",
|
||||
label: type,
|
||||
publisherAndContractName: "deployer.thirdweb.eth/DropERC20",
|
||||
chainId: chainId,
|
||||
deploymentType: "asset",
|
||||
};
|
||||
}
|
||||
|
||||
// example: asset.claim-conditions.attempt
|
||||
export function getTokenStepTrackingData(params: {
|
||||
action: "claim-conditions" | "airdrop" | "mint";
|
||||
chainId: number;
|
||||
status: "attempt" | "success" | "error";
|
||||
}) {
|
||||
return {
|
||||
category: "asset",
|
||||
action: params.action,
|
||||
contractType: "DropERC20",
|
||||
label: params.status,
|
||||
chainId: params.chainId,
|
||||
};
|
||||
}
|
||||
|
||||
// example: asset.launch.attempt
|
||||
export function getLaunchTrackingData(params: {
|
||||
chainId: number;
|
||||
airdropEnabled: boolean;
|
||||
saleEnabled: boolean;
|
||||
type: "attempt" | "success" | "error";
|
||||
}) {
|
||||
return {
|
||||
category: "asset",
|
||||
action: "launch",
|
||||
label: params.type,
|
||||
contractType: "DropERC20",
|
||||
chainId: params.chainId,
|
||||
airdropEnabled: params.airdropEnabled,
|
||||
saleEnabled: params.saleEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
// example: asset.info-page.next-click
|
||||
export function getStepCardTrackingData(params: {
|
||||
step: "info" | "distribution" | "launch";
|
||||
click: "prev" | "next";
|
||||
}) {
|
||||
return {
|
||||
category: "asset",
|
||||
action: `${params.step}-page`,
|
||||
label: `${params.click}-click`,
|
||||
contractType: "DropERC20",
|
||||
};
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { ClientOnly } from "../../../../../../../components/ClientOnly/ClientOnly";
|
||||
import { ContractTable } from "../../../../../../../components/contract-components/tables/contract-table";
|
||||
import { getSortedDeployedContracts } from "../../../../../account/contracts/_components/getSortedDeployedContracts";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { Cards } from "./cards";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const [authToken, team, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/assets`);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<AssetsHeader />
|
||||
<div className="container max-w-7xl pt-8 pb-20">
|
||||
<Cards
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
client={client}
|
||||
teamId={team.id}
|
||||
projectId={project.id}
|
||||
/>
|
||||
|
||||
<div className="mt-10 mb-3">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Your assets</h2>
|
||||
<p className="text-muted-foreground">
|
||||
List of all assets created or imported into this project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<AssetsPageAsync
|
||||
teamId={team.id}
|
||||
projectId={project.id}
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetsHeader() {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container max-w-7xl py-10">
|
||||
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Assets
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create and Manage tokens for your project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function AssetsPageAsync(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const deployedContracts = await getSortedDeployedContracts({
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
authToken: props.authToken,
|
||||
deploymentType: "asset",
|
||||
});
|
||||
|
||||
return (
|
||||
<ClientOnly ssr={<GenericLoadingPage />}>
|
||||
<ContractTable
|
||||
variant="asset"
|
||||
contracts={deployedContracts}
|
||||
pageSize={10}
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
client={props.client}
|
||||
/>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
+5
-4
@@ -14,10 +14,10 @@ import {
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import { ContractIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
|
||||
import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
import { InsightIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
|
||||
import { NebulaIcon } from "../../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
import { ContractIcon } from "../../../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
|
||||
import { EngineIcon } from "../../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
import { InsightIcon } from "../../../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
|
||||
import { NebulaIcon } from "../../../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
import { ClientIDSection } from "./ClientIDSection";
|
||||
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
|
||||
import { SecretKeySection } from "./SecretKeySection";
|
||||
@@ -63,6 +63,7 @@ function IntegrateAPIKeySection({
|
||||
{secretKeyMasked && (
|
||||
<SecretKeySection
|
||||
secretKeyMasked={secretKeyMasked}
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
/>
|
||||
)}
|
||||
+5
-1
@@ -6,6 +6,7 @@ import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage
|
||||
|
||||
export function SecretKeySection(props: {
|
||||
secretKeyMasked: string;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked);
|
||||
@@ -26,7 +27,10 @@ export function SecretKeySection(props: {
|
||||
|
||||
<RotateSecretKeyButton
|
||||
rotateSecretKey={async () => {
|
||||
return rotateSecretKeyClient(props.projectId);
|
||||
return rotateSecretKeyClient({
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
});
|
||||
}}
|
||||
onSuccess={(data) => {
|
||||
setSecretKeyMasked(data.data.secretMasked);
|
||||
+19
-12
@@ -1,19 +1,20 @@
|
||||
"use client";
|
||||
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
BookTextIcon,
|
||||
BoxIcon,
|
||||
CoinsIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
|
||||
import { EngineIcon } from "../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
import { InsightIcon } from "../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
|
||||
import { PayIcon } from "../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
|
||||
import { SmartAccountIcon } from "../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
|
||||
import { Badge } from "../../../../../../@/components/ui/badge";
|
||||
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
import { ContractIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
|
||||
import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
import { InsightIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
|
||||
import { PayIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
|
||||
import { SmartAccountIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
|
||||
import { NebulaIcon } from "../../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
|
||||
export function ProjectSidebarLayout(props: {
|
||||
layoutPath: string;
|
||||
@@ -52,11 +53,7 @@ export function ProjectSidebarLayout(props: {
|
||||
{
|
||||
href: `${layoutPath}/connect/universal-bridge`,
|
||||
icon: PayIcon,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Universal Bridge <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
label: "Universal Bridge",
|
||||
tracking: tracking("universal-bridge"),
|
||||
},
|
||||
{
|
||||
@@ -65,6 +62,16 @@ export function ProjectSidebarLayout(props: {
|
||||
icon: ContractIcon,
|
||||
tracking: tracking("contracts"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/assets`,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Assets <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
icon: CoinsIcon,
|
||||
tracking: tracking("assets"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/engine`,
|
||||
label: (
|
||||
+1
-1
@@ -15,7 +15,7 @@ import {
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
import type { RpcMethodStats } from "types/analytics";
|
||||
import { EmptyStateCard } from "../../../../components/Analytics/EmptyStateCard";
|
||||
import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard";
|
||||
|
||||
export function RpcMethodBarChartCardUI({
|
||||
rawData,
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { fetchDashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashb
|
||||
import { type ThirdwebClient, defineChain, getContract } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import type { TransactionStats } from "types/analytics";
|
||||
import { TransactionsChartCardUI } from "../../../(team)/_components/TransactionsCard";
|
||||
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
|
||||
import { TransactionsChartCardUI } from "../../../../(team)/_components/TransactionsCard";
|
||||
import { PieChartCard } from "../../../../../components/Analytics/PieChartCard";
|
||||
|
||||
export function TransactionsChartsUI({
|
||||
data,
|
||||
+4
-3
@@ -13,9 +13,9 @@ import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { defineChain, getContract } from "thirdweb";
|
||||
import { getCompilerMetadata } from "thirdweb/contract";
|
||||
import { getSortedDeployedContracts } from "../../../../../../account/contracts/_components/getSortedDeployedContracts";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../login/loginRedirect";
|
||||
import { getSortedDeployedContracts } from "../../../../../../../account/contracts/_components/getSortedDeployedContracts";
|
||||
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../../login/loginRedirect";
|
||||
|
||||
const trackingCategory = "smart-wallet";
|
||||
|
||||
@@ -113,6 +113,7 @@ async function AsyncYourFactories(props: {
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
authToken: props.authToken,
|
||||
deploymentType: undefined,
|
||||
});
|
||||
|
||||
const factories = (
|
||||
+2
-2
@@ -1,6 +1,7 @@
|
||||
import { getUserOpUsage } from "@/api/analytics";
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import {
|
||||
type Range,
|
||||
getLastNDaysRange,
|
||||
@@ -9,8 +10,7 @@ import { AccountAbstractionAnalytics } from "components/smart-wallets/AccountAbs
|
||||
import { AccountAbstractionSummary } from "components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import type { SearchParams } from "nuqs/server";
|
||||
import { getClientThirdwebClient } from "../../../../../../../@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import { searchParamLoader } from "./search-params";
|
||||
|
||||
interface PageParams {
|
||||
+3
-3
@@ -3,12 +3,12 @@ import { getTeamBySlug } from "@/api/team";
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { AccountAbstractionSettingsPage } from "components/smart-wallets/SponsorshipPolicies";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getClientThirdwebClient } from "../../../../../../../../@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan";
|
||||
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
|
||||
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user