Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309d8f81a9 | ||
|
|
9d72ddd604 | ||
|
|
17b83686e4 | ||
|
|
8ee427d590 | ||
|
|
24319f4570 | ||
|
|
f38b262cd0 | ||
|
|
5b35a6227b | ||
|
|
03e238515b | ||
|
|
72875f9de5 | ||
|
|
97ce74f307 | ||
|
|
3a43fb23a4 | ||
|
|
c7cb6ae7a4 | ||
|
|
7ecfcb95af | ||
|
|
4cf15a2475 | ||
|
|
62ce05ec35 | ||
|
|
563c4c7df1 | ||
|
|
32c91abc02 | ||
|
|
7a2dc25129 | ||
|
|
32568fd421 | ||
|
|
9091e813ab | ||
|
|
23d375778b | ||
|
|
e7b0110803 | ||
|
|
1825ac857e | ||
|
|
3ce2d0602e | ||
|
|
4bde2eb157 | ||
|
|
8d4d991e57 | ||
|
|
22090cc400 | ||
|
|
95a6c38703 | ||
|
|
b1060de7da | ||
|
|
0aca56317a | ||
|
|
5463baa7da | ||
|
|
64ffe199af | ||
|
|
76b522e7bc | ||
|
|
49786404b3 | ||
|
|
64bd603301 | ||
|
|
60a5b7c3e7 | ||
|
|
baf95b9077 | ||
|
|
e29f749342 | ||
|
|
2a7df337f7 | ||
|
|
348e3ab4ad | ||
|
|
e4590a7ada | ||
|
|
a45c558e06 | ||
|
|
a3e7300723 | ||
|
|
4c1f384a63 | ||
|
|
ec099a781a | ||
|
|
185d2f309c | ||
|
|
e8fb41752e | ||
|
|
3f42f522b7 | ||
|
|
bb29e1f705 | ||
|
|
76822982f3 | ||
|
|
52261a5740 | ||
|
|
cd30ba4f3a | ||
|
|
0ea898712b | ||
|
|
1b2caecfc4 | ||
|
|
e574114188 | ||
|
|
aa733b7b3e | ||
|
|
fa0d1141da | ||
|
|
f618e2bffb | ||
|
|
3ab31c8210 | ||
|
|
8eb5d8c01e | ||
|
|
d036b87ae1 | ||
|
|
c86cac4d61 | ||
|
|
af09576247 | ||
|
|
316cef1a58 | ||
|
|
1bfcd375e4 | ||
|
|
86070fa309 | ||
|
|
c5bf5a8594 | ||
|
|
c1d6bb7848 | ||
|
|
e9db59e8da | ||
|
|
1db216545a | ||
|
|
0a798c58b3 | ||
|
|
cfacfc28a8 | ||
|
|
2d978aea35 | ||
|
|
ca382f442a | ||
|
|
840b1a264f | ||
|
|
d803d37879 | ||
|
|
e10c1e696e | ||
|
|
a2305329fd | ||
|
|
60235ade34 | ||
|
|
811384b91f | ||
|
|
22dc4d7cd1 | ||
|
|
3074e7c67d | ||
|
|
dd99410ccd | ||
|
|
b4e5dc1f3c | ||
|
|
8b4091e848 | ||
|
|
2d15e6f509 | ||
|
|
e450d320a0 | ||
|
|
838f8fa547 | ||
|
|
1dae74b9d2 | ||
|
|
672f7d00bd | ||
|
|
a9eda9fc8d | ||
|
|
a3b4720f69 | ||
|
|
c2a5f65239 | ||
|
|
7ab76ac55a | ||
|
|
386138e4f0 | ||
|
|
309d1d544b | ||
|
|
59be4ea2fb | ||
|
|
9c737a1905 | ||
|
|
646a5a3788 | ||
|
|
7597418872 | ||
|
|
5204bd506f | ||
|
|
663627715a | ||
|
|
8f7bcaeadc |
@@ -16,6 +16,7 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TW_SECRET_KEY: ${{ secrets.TW_SECRET_KEY }}
|
||||
TW_CLIENT_ID: ${{ secrets.TW_CLIENT_ID }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jobs:
|
||||
@@ -102,7 +103,7 @@ jobs:
|
||||
runs-on: ubuntu-latest-8
|
||||
strategy:
|
||||
matrix:
|
||||
package_manager: [npm, yarn, pnpm, bun]
|
||||
package_manager: [pnpm] # TODO, reenable [npm, yarn, pnpm, bun]
|
||||
bundler: [vite, webpack, esbuild]
|
||||
steps:
|
||||
- name: Check out the code
|
||||
@@ -123,7 +124,14 @@ jobs:
|
||||
mkdir test-project
|
||||
cd test-project
|
||||
npm init -y
|
||||
${{ matrix.package_manager }} add react react-dom ../packages/thirdweb
|
||||
|
||||
# Handle different package managers
|
||||
if [ "${{ matrix.package_manager }}" = "pnpm" ]; then
|
||||
# Create pnpm workspace
|
||||
echo '{"name": "test-project", "private": true, "workspaces": ["."]}' > package.json
|
||||
echo '{"packages": ["../packages/*"]}' > pnpm-workspace.yaml
|
||||
pnpm add react react-dom ../packages/thirdweb -w
|
||||
fi
|
||||
- name: Create test file
|
||||
run: |
|
||||
cd test-project
|
||||
@@ -133,7 +141,7 @@ jobs:
|
||||
if: matrix.bundler == 'vite'
|
||||
run: |
|
||||
cd test-project
|
||||
${{matrix.package_manager}} add vite
|
||||
${{matrix.package_manager}} add vite -w
|
||||
echo 'import { defineConfig } from "vite"; import {resolve} from "path"; export default defineConfig({ build: { lib: { entry: resolve(__dirname, "index.js"), name: "e2e_test" }, outDir: "dist" }});' > vite.config.js
|
||||
npx vite build
|
||||
|
||||
@@ -141,7 +149,7 @@ jobs:
|
||||
if: matrix.bundler == 'webpack'
|
||||
run: |
|
||||
cd test-project
|
||||
${{matrix.package_manager}} add webpack webpack-cli
|
||||
${{matrix.package_manager}} add webpack webpack-cli -w
|
||||
echo 'const path = require("path"); module.exports = { mode: "production", entry: "./index.js", output: { path: path.resolve(__dirname, "dist"), filename: "bundle.js" }};' > webpack.config.js
|
||||
npx webpack
|
||||
|
||||
@@ -149,7 +157,7 @@ jobs:
|
||||
if: matrix.bundler == 'esbuild'
|
||||
run: |
|
||||
cd test-project
|
||||
${{matrix.package_manager}} add esbuild
|
||||
${{matrix.package_manager}} add esbuild -w
|
||||
npx esbuild index.js --bundle --outdir=dist
|
||||
|
||||
- name: Verify bundle
|
||||
@@ -177,6 +185,9 @@ jobs:
|
||||
- name: Setup & Install
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Build Packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Report bundle size
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
public-hoist-pattern[]=*import-in-the-middle*
|
||||
public-hoist-pattern[]=*require-in-the-middle*
|
||||
public-hoist-pattern[]=*pino-pretty*
|
||||
@@ -58,4 +58,7 @@ module.exports = [
|
||||
"/faucets",
|
||||
// -- brand kit --
|
||||
"/brand-kit",
|
||||
// -- universal bridge landing pages --
|
||||
"/universal-bridge-regions/:region_slug",
|
||||
"/enterprise",
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"ignoreDependencies": [
|
||||
"@storybook/blocks",
|
||||
"@thirdweb-dev/service-utils",
|
||||
"@types/color"
|
||||
"@types/color",
|
||||
"fast-xml-parser"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @ts-check
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { XMLParser } = require("fast-xml-parser");
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<Array<{chainId: number, name: string, slug: string}>>}
|
||||
*/
|
||||
async function fetchChainsFromApi() {
|
||||
const res = await fetch("https://api.thirdweb.com/v1/chains", {
|
||||
@@ -82,16 +84,20 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
additionalPaths: async (config) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const FRAMER_PATHS = require("./framer-rewrites");
|
||||
const allChains = await fetchChainsFromApi();
|
||||
const [framerUrls, allChains] = await Promise.all([
|
||||
getFramerXML(),
|
||||
fetchChainsFromApi(),
|
||||
]);
|
||||
|
||||
return [
|
||||
...FRAMER_PATHS.map((path) => ({
|
||||
loc: path,
|
||||
changefreq: config.changefreq,
|
||||
priority: config.priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
})),
|
||||
...framerUrls.map((url) => {
|
||||
return {
|
||||
loc: url.loc,
|
||||
changefreq: config.changefreq,
|
||||
priority: config.priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
};
|
||||
}),
|
||||
...allChains.map((chain) => {
|
||||
return {
|
||||
loc: `/${chain.slug}`,
|
||||
@@ -136,3 +142,19 @@ async function createSearchRecordSitemaps(config) {
|
||||
// filter out any failed requests
|
||||
return chainsForLines.filter(Boolean);
|
||||
}
|
||||
|
||||
async function getFramerXML() {
|
||||
const framerSiteMapText = await fetch(
|
||||
"https://landing.thirdweb.com/sitemap.xml",
|
||||
).then((res) => res.text());
|
||||
|
||||
const parser = new XMLParser();
|
||||
const xmlObject = parser.parse(framerSiteMapText);
|
||||
|
||||
/**
|
||||
* @type {Array<{loc: string}>}
|
||||
*/
|
||||
const urls = xmlObject.urlset.url;
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const ContentSecurityPolicy = `
|
||||
style-src 'self' 'unsafe-inline' vercel.live;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com static.reo.dev;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
@@ -203,7 +203,6 @@ function getConfig(): NextConfig {
|
||||
type: "memory",
|
||||
});
|
||||
}
|
||||
config.externals.push("pino-pretty");
|
||||
config.module = {
|
||||
...config.module,
|
||||
exprContextCritical: false,
|
||||
|
||||
+46
-44
@@ -29,29 +29,29 @@
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "1.1.8",
|
||||
"@sentry/nextjs": "9.5.0",
|
||||
"@shazow/whatsabi": "0.20.0",
|
||||
"@tanstack/react-query": "5.67.3",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-dialog": "1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-hover-card": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.3",
|
||||
"@radix-ui/react-radio-group": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "1.2.0",
|
||||
"@sentry/nextjs": "9.12.0",
|
||||
"@shazow/whatsabi": "0.21.0",
|
||||
"@tanstack/react-query": "5.72.1",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/functions": "2.0.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"abitype": "1.0.8",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -59,6 +59,7 @@
|
||||
"color": "5.0.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "12.5.0",
|
||||
@@ -67,7 +68,7 @@
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.0",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.483.0",
|
||||
"lucide-react": "0.487.0",
|
||||
"next": "15.2.4",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -77,25 +78,26 @@
|
||||
"papaparse": "^5.5.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.67.1",
|
||||
"prettier": "3.5.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.1",
|
||||
"recharts": "2.15.2",
|
||||
"remark-gfm": "4.0.1",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "2.0.1",
|
||||
"sonner": "2.0.3",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.20.1",
|
||||
"swagger-ui-react": "^5.20.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
@@ -109,38 +111,38 @@
|
||||
"@next/bundle-analyzer": "15.2.4",
|
||||
"@next/eslint-plugin-next": "15.2.4",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@storybook/addon-essentials": "8.6.4",
|
||||
"@storybook/addon-interactions": "8.6.4",
|
||||
"@storybook/addon-links": "8.6.4",
|
||||
"@storybook/addon-onboarding": "8.6.4",
|
||||
"@storybook/addon-viewport": "8.6.4",
|
||||
"@storybook/blocks": "8.6.4",
|
||||
"@storybook/nextjs": "8.6.4",
|
||||
"@storybook/react": "8.6.4",
|
||||
"@storybook/test": "8.6.4",
|
||||
"@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",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.13.10",
|
||||
"@types/node": "22.14.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"checkly": "5.0.1",
|
||||
"checkly": "5.2.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"knip": "5.46.0",
|
||||
"knip": "5.47.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.3",
|
||||
"storybook": "8.6.4",
|
||||
"storybook": "8.6.12",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.2"
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export async function getWalletNFTs(params: {
|
||||
const result = await transformMoralisResponseToNFT(
|
||||
await parsedResponse,
|
||||
owner,
|
||||
chainId,
|
||||
);
|
||||
|
||||
return { result };
|
||||
@@ -194,6 +195,8 @@ async function getWalletNFTsFromInsight(params: {
|
||||
tokenURI: nft.metadata_url,
|
||||
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
|
||||
supply: nft.balance,
|
||||
tokenAddress: nft.contract.address,
|
||||
chainId: nft.contract.chain_id,
|
||||
};
|
||||
|
||||
return walletNFT;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function joinTeamWaitlist(options: {
|
||||
teamSlug: string;
|
||||
// currently only 'nebula' is supported
|
||||
scope: "nebula";
|
||||
}) {
|
||||
const { teamSlug, scope } = options;
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scope,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
errorMessage: "Failed to join waitlist",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function getStripe() {
|
||||
|
||||
export async function getTeamInvoices(
|
||||
team: Team,
|
||||
options?: { cursor?: string },
|
||||
options?: { cursor?: string; status?: "open" },
|
||||
) {
|
||||
try {
|
||||
const customerId = team.stripeCustomerId;
|
||||
@@ -37,6 +37,8 @@ export async function getTeamInvoices(
|
||||
customer: customerId,
|
||||
limit: 10,
|
||||
starting_after: options?.cursor,
|
||||
// Only return open invoices if the status is open
|
||||
status: options?.status,
|
||||
});
|
||||
|
||||
return invoices;
|
||||
|
||||
@@ -346,7 +346,7 @@ export async function getEcosystemWalletUsage(args: {
|
||||
searchParams.append("period", period);
|
||||
}
|
||||
const res = await fetchAnalytics(
|
||||
`v2/wallets/connects/${ecosystemSlug}?${searchParams.toString()}`,
|
||||
`v2/wallet/connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -356,7 +356,10 @@ export async function getEcosystemWalletUsage(args: {
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch ecosystem wallet stats");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch ecosystem wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,31 +74,3 @@ export async function getDefaultTeam() {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type TeamNebulaWaitList = {
|
||||
onWaitlist: boolean;
|
||||
createdAt: null | string;
|
||||
};
|
||||
|
||||
export async function getTeamNebulaWaitList(teamSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist?scope=nebula`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
return (await res.json()).result as TeamNebulaWaitList;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
export type RPCUsageDataItem = {
|
||||
date: string;
|
||||
usageType: "included" | "overage" | "rate-limit";
|
||||
count: string;
|
||||
};
|
||||
|
||||
export const fetchRPCUsage = unstable_cache(
|
||||
async (params: {
|
||||
teamId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
from: string;
|
||||
to: string;
|
||||
period: "day" | "week" | "month" | "year" | "all";
|
||||
}) => {
|
||||
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
|
||||
const url = new URL(`${analyticsEndpoint}/v2/rpc/usage-types`);
|
||||
url.searchParams.set("teamId", params.teamId);
|
||||
if (params.projectId) {
|
||||
url.searchParams.set("projectId", params.projectId);
|
||||
}
|
||||
url.searchParams.set("from", params.from);
|
||||
url.searchParams.set("to", params.to);
|
||||
url.searchParams.set("period", params.period);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
data: resData.data as RPCUsageDataItem[],
|
||||
};
|
||||
},
|
||||
["rpc-usage"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
type Last24HoursRPCUsageApiResponse = {
|
||||
peakRate: {
|
||||
date: string;
|
||||
peakRPS: string;
|
||||
};
|
||||
averageRate: {
|
||||
date: string;
|
||||
averageRate: string;
|
||||
peakRPS: string;
|
||||
includedCount: string;
|
||||
rateLimitedCount: string;
|
||||
overageCount: string;
|
||||
}[];
|
||||
totalCounts: {
|
||||
includedCount: string;
|
||||
rateLimitedCount: string;
|
||||
overageCount: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const getLast24HoursRPCUsage = unstable_cache(
|
||||
async (params: {
|
||||
teamId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
}) => {
|
||||
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
|
||||
const url = new URL(`${analyticsEndpoint}/v2/rpc/24-hours`);
|
||||
url.searchParams.set("teamId", params.teamId);
|
||||
if (params.projectId) {
|
||||
url.searchParams.set("projectId", params.projectId);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
data: resData.data as Last24HoursRPCUsageApiResponse,
|
||||
};
|
||||
},
|
||||
["rpc-usage-last-24-hours:v2"],
|
||||
{
|
||||
revalidate: 60, // 1 minute
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
GetBillingCheckoutUrlAction,
|
||||
@@ -8,6 +10,7 @@ import type {
|
||||
GetBillingPortalUrlAction,
|
||||
GetBillingPortalUrlOptions,
|
||||
} from "../actions/billing";
|
||||
import type { Team } from "../api/team";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Spinner } from "./ui/Spinner/Spinner";
|
||||
import { Button, type ButtonProps } from "./ui/button";
|
||||
@@ -16,6 +19,7 @@ type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
buttonProps?: Omit<ButtonProps, "children">;
|
||||
children: React.ReactNode;
|
||||
billingStatus: Team["billingStatus"];
|
||||
};
|
||||
|
||||
export function CheckoutButton({
|
||||
@@ -25,6 +29,7 @@ export function CheckoutButton({
|
||||
getBillingCheckoutUrl,
|
||||
children,
|
||||
buttonProps,
|
||||
billingStatus,
|
||||
}: CheckoutButtonProps) {
|
||||
const getUrlMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -40,35 +45,65 @@ export function CheckoutButton({
|
||||
const errorMessage = "Failed to open checkout page";
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
className={cn(buttonProps?.className, "gap-2")}
|
||||
disabled={getUrlMutation.isPending || buttonProps?.disabled}
|
||||
onClick={async (e) => {
|
||||
buttonProps?.onClick?.(e);
|
||||
getUrlMutation.mutate(undefined, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.url) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
{/* show warning if the team has an invalid payment method */}
|
||||
{billingStatus === "invalidPayment" && (
|
||||
<BillingWarning teamSlug={teamSlug} />
|
||||
)}
|
||||
<Button
|
||||
{...buttonProps}
|
||||
className={cn(buttonProps?.className, "w-full gap-2")}
|
||||
disabled={
|
||||
// disable button if the team has an invalid payment method
|
||||
// api will return 402 error if the team has an invalid payment method
|
||||
billingStatus === "invalidPayment" ||
|
||||
getUrlMutation.isPending ||
|
||||
buttonProps?.disabled
|
||||
}
|
||||
onClick={async (e) => {
|
||||
buttonProps?.onClick?.(e);
|
||||
getUrlMutation.mutate(undefined, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.url) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = window.open(res.url, "_blank");
|
||||
const tab = window.open(res.url, "_blank");
|
||||
|
||||
if (!tab) {
|
||||
if (!tab) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{getUrlMutation.isPending && <Spinner className="size-4" />}
|
||||
{children}
|
||||
</Button>
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{getUrlMutation.isPending && <Spinner className="size-4" />}
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingWarning({ teamSlug }: { teamSlug: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">
|
||||
You have outstanding invoices. Please{" "}
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
className="font-medium text-amber-700 underline transition-colors hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-300"
|
||||
>
|
||||
pay them
|
||||
</Link>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MultiSelect } from "@/components/blocks/multi-select";
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ChainIcon } from "../../../components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "../../../components/icons/ChainIcon";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
|
||||
function cleanChainName(chainName: string) {
|
||||
@@ -19,6 +19,7 @@ export function MultiNetworkSelector(props: {
|
||||
disableChainId?: boolean;
|
||||
className?: string;
|
||||
priorityChains?: number[];
|
||||
hideTestnets?: boolean;
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
@@ -44,13 +45,17 @@ export function MultiNetworkSelector(props: {
|
||||
sortedChains = [...priorityChains, ...otherChains];
|
||||
}
|
||||
|
||||
if (props.hideTestnets) {
|
||||
sortedChains = sortedChains.filter((chain) => !chain.testnet);
|
||||
}
|
||||
|
||||
return sortedChains.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [allChains, props.priorityChains, idToChain]);
|
||||
}, [allChains, props.priorityChains, idToChain, props.hideTestnets]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
@@ -77,7 +82,7 @@ export function MultiNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
@@ -172,7 +177,7 @@ export function SingleNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
|
||||
@@ -15,6 +15,37 @@ type AppFooterProps = {
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
const footerLinks = [
|
||||
{
|
||||
href: "/home",
|
||||
label: "Home",
|
||||
},
|
||||
{
|
||||
href: "https://blog.thirdweb.com",
|
||||
label: "Blog",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/changelog",
|
||||
label: "Changelog",
|
||||
},
|
||||
{
|
||||
href: "https://feedback.thirdweb.com/",
|
||||
label: "Feedback",
|
||||
},
|
||||
{
|
||||
href: "https://thirdweb.com/privacy-policy",
|
||||
label: "Privacy Policy",
|
||||
},
|
||||
{
|
||||
href: "https://thirdweb.com/terms",
|
||||
label: "Terms of Service",
|
||||
},
|
||||
{
|
||||
href: "https://thirdweb.com/chainlist",
|
||||
label: "Chainlist",
|
||||
},
|
||||
];
|
||||
|
||||
export function AppFooter(props: AppFooterProps) {
|
||||
return (
|
||||
<footer
|
||||
@@ -80,57 +111,28 @@ export function AppFooter(props: AppFooterProps) {
|
||||
</div>
|
||||
{/* bottom row */}
|
||||
<div className="grid grid-flow-col grid-cols-2 grid-rows-5 gap-2 md:flex md:flex-row md:justify-between">
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="/home"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://blog.thirdweb.com"
|
||||
target="_blank"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://portal.thirdweb.com/changelog"
|
||||
target="_blank"
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://feedback.thirdweb.com/"
|
||||
target="_blank"
|
||||
>
|
||||
Feedback
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/privacy-policy"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/terms"
|
||||
target="_blank"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
href="https://thirdweb.com/chainlist"
|
||||
target="_blank"
|
||||
>
|
||||
Chain List
|
||||
</Link>
|
||||
{footerLinks.map((link) => (
|
||||
<FooterLink key={link.href} {...link} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink(props: {
|
||||
href: string;
|
||||
label: string;
|
||||
prefetch?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={props.href}
|
||||
prefetch={false}
|
||||
className="px-0 py-[6px] text-muted-foreground text-sm hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{props.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "@/components/ui/chart";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
@@ -30,11 +30,19 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
showLegend?: boolean;
|
||||
|
||||
yAxis?: boolean;
|
||||
xAxis?: {
|
||||
sameDay?: boolean;
|
||||
};
|
||||
|
||||
variant?: "stacked" | "individual";
|
||||
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
isPending: boolean;
|
||||
@@ -60,6 +68,8 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
{props.customHeader && props.customHeader}
|
||||
|
||||
<CardContent className={cn(!props.header && "pt-6")}>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
@@ -69,15 +79,21 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
{props.yAxis && <YAxis tickLine={false} axisLine={false} />}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={20}
|
||||
tickFormatter={(value) => formatDate(new Date(value), "MMM dd")}
|
||||
tickFormatter={(value) =>
|
||||
formatDate(
|
||||
new Date(value),
|
||||
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
cursor={true}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel={
|
||||
@@ -110,17 +126,29 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{configKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
dataKey={key}
|
||||
type="natural"
|
||||
fill={`url(#fill_${key})`}
|
||||
fillOpacity={0.4}
|
||||
stroke={`var(--color-${key})`}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
{configKeys.map((key) =>
|
||||
key === "maxLine" ? (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey="maxLine"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
fill="none"
|
||||
/>
|
||||
) : (
|
||||
<Area
|
||||
key={key}
|
||||
dataKey={key}
|
||||
type="natural"
|
||||
fill={`url(#fill_${key})`}
|
||||
fillOpacity={0.4}
|
||||
stroke={`var(--color-${key})`}
|
||||
stackId={props.variant !== "stacked" ? undefined : "a"}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{props.showLegend && (
|
||||
<ChartLegend
|
||||
|
||||
@@ -3,33 +3,36 @@ import type { Team } from "@/api/team";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import { CheckIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { TEAM_PLANS } from "utils/pricing";
|
||||
import { useTrack } from "../../../hooks/analytics/useTrack";
|
||||
import { remainingDays } from "../../../utils/date-utils";
|
||||
import type { GetBillingCheckoutUrlAction } from "../../actions/billing";
|
||||
import type { ProductSKU } from "../../lib/billing";
|
||||
import { CheckoutButton } from "../billing";
|
||||
|
||||
type ButtonProps = React.ComponentProps<typeof Button>;
|
||||
|
||||
const PRO_CONTACT_US_URL =
|
||||
"https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro";
|
||||
type PricingCardCta = {
|
||||
hint?: string;
|
||||
title: string;
|
||||
onClick?: () => void;
|
||||
} & (
|
||||
| {
|
||||
type: "link";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "checkout";
|
||||
}
|
||||
);
|
||||
|
||||
type PricingCardProps = {
|
||||
teamSlug: string;
|
||||
billingPlan: Exclude<Team["billingPlan"], "free">;
|
||||
cta?: {
|
||||
hint?: string;
|
||||
title: string;
|
||||
tracking: {
|
||||
category: string;
|
||||
label?: string;
|
||||
};
|
||||
variant?: ButtonProps["variant"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
billingStatus: Team["billingStatus"];
|
||||
billingPlan: keyof typeof TEAM_PLANS;
|
||||
cta?: PricingCardCta;
|
||||
ctaHint?: string;
|
||||
highlighted?: boolean;
|
||||
current?: boolean;
|
||||
@@ -39,6 +42,7 @@ type PricingCardProps = {
|
||||
|
||||
export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
teamSlug,
|
||||
billingStatus,
|
||||
billingPlan,
|
||||
cta,
|
||||
highlighted = false,
|
||||
@@ -49,13 +53,23 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
const plan = TEAM_PLANS[billingPlan];
|
||||
const isCustomPrice = typeof plan.price === "string";
|
||||
|
||||
const trackEvent = useTrack();
|
||||
const remainingTrialDays =
|
||||
(activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0;
|
||||
|
||||
const handleCTAClick = () => {
|
||||
cta?.onClick?.();
|
||||
trackEvent({
|
||||
category: "account",
|
||||
label: `${billingPlan}Plan`,
|
||||
action: "click",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-10 flex w-full flex-col gap-6 rounded-xl border border-border bg-card p-4 md:p-6",
|
||||
"z-10 flex w-full flex-col gap-4 rounded-xl border border-border bg-card p-4",
|
||||
current && "border-blue-500",
|
||||
highlighted && "border-active-border",
|
||||
)}
|
||||
@@ -71,13 +85,13 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Title + Desc */}
|
||||
<div>
|
||||
<div className="mb-2 flex flex-row items-center gap-2">
|
||||
<div className="mb-1 flex flex-row items-center gap-3">
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">
|
||||
{plan.title}
|
||||
</h3>
|
||||
{current && <Badge className="capitalize">Current plan</Badge>}
|
||||
</div>
|
||||
<p className="max-w-[320px] text-muted-foreground">
|
||||
<p className="max-w-[320px] text-muted-foreground text-sm">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -85,30 +99,13 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
{/* Price */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-3xl text-foreground tracking-tight">
|
||||
<span className="font-semibold text-2xl text-foreground tracking-tight">
|
||||
${plan.price}
|
||||
</span>
|
||||
|
||||
{!isCustomPrice && (
|
||||
<span className="text-muted-foreground">/ month</span>
|
||||
)}
|
||||
|
||||
{billingPlan === "starter" && (
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[320px]"
|
||||
label="We will place a temporary hold of $25 to verify your card, this will be immediately released back to you after verification."
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-auto p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<div>
|
||||
<CircleAlertIcon className="size-5 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{remainingTrialDays > 0 && (
|
||||
@@ -124,7 +121,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
|
||||
<div className="flex grow flex-col items-start gap-2 text-foreground">
|
||||
{plan.subTitle && (
|
||||
<p className="font-medium text-foreground">{plan.subTitle}</p>
|
||||
<p className="font-medium text-foreground text-sm">{plan.subTitle}</p>
|
||||
)}
|
||||
|
||||
{plan.features.map((f) => (
|
||||
@@ -134,29 +131,31 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
|
||||
{cta && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{billingPlan !== "pro" ? (
|
||||
{billingPlanToSkuMap[billingPlan] && cta.type === "checkout" && (
|
||||
<CheckoutButton
|
||||
billingStatus={billingStatus}
|
||||
buttonProps={{
|
||||
variant: cta.variant || "outline",
|
||||
className: "gap-2",
|
||||
onClick: cta.onClick,
|
||||
variant: highlighted ? "default" : "outline",
|
||||
className: highlighted ? undefined : "bg-background",
|
||||
onClick: handleCTAClick,
|
||||
}}
|
||||
teamSlug={teamSlug}
|
||||
sku={billingPlan === "starter" ? "plan:starter" : "plan:growth"}
|
||||
sku={billingPlanToSkuMap[billingPlan]}
|
||||
getBillingCheckoutUrl={getBillingCheckoutUrl}
|
||||
>
|
||||
{cta.title}
|
||||
</CheckoutButton>
|
||||
) : (
|
||||
<Button variant={cta.variant || "outline"} asChild>
|
||||
<TrackedLinkTW
|
||||
href={PRO_CONTACT_US_URL}
|
||||
label={cta.tracking?.label}
|
||||
category={cta.tracking?.category}
|
||||
target="_blank"
|
||||
>
|
||||
)}
|
||||
|
||||
{cta.type === "link" && (
|
||||
<Button
|
||||
variant={highlighted ? "default" : "outline"}
|
||||
className={highlighted ? undefined : "bg-background"}
|
||||
asChild
|
||||
>
|
||||
<Link href={cta.href} target="_blank" onClick={handleCTAClick}>
|
||||
{cta.title}
|
||||
</TrackedLinkTW>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -171,6 +170,19 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const billingPlanToSkuMap: Record<Team["billingPlan"], ProductSKU | undefined> =
|
||||
{
|
||||
starter: "plan:starter",
|
||||
growth: "plan:growth",
|
||||
accelerate: "plan:accelerate",
|
||||
scale: "plan:scale",
|
||||
// we can't render checkout buttons for these plans:
|
||||
pro: undefined,
|
||||
free: undefined,
|
||||
growth_legacy: undefined,
|
||||
starter_legacy: undefined,
|
||||
};
|
||||
|
||||
type FeatureItemProps = {
|
||||
text: string | string[];
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export function WalletAddress(props: {
|
||||
address: string | undefined;
|
||||
shortenAddress?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
// default back to zero address if no address provided
|
||||
@@ -78,6 +79,7 @@ export function WalletAddress(props: {
|
||||
address={address}
|
||||
profiles={profiles.data || []}
|
||||
thirdwebClient={thirdwebClient}
|
||||
iconClassName={props.iconClassName}
|
||||
/>
|
||||
)}
|
||||
<span className="cursor-pointer font-mono">
|
||||
@@ -168,6 +170,7 @@ function WalletAvatar(props: {
|
||||
address: string;
|
||||
profiles: SocialProfile[];
|
||||
thirdwebClient: ThirdwebClient;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const avatar = useMemo(() => {
|
||||
return props.profiles.find(
|
||||
@@ -186,11 +189,20 @@ function WalletAvatar(props: {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="size-6 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("size-6 overflow-hidden rounded-full", props.iconClassName)}
|
||||
>
|
||||
{resolvedAvatarSrc ? (
|
||||
<Img src={resolvedAvatarSrc} className="size-6 object-cover" />
|
||||
<Img
|
||||
src={resolvedAvatarSrc}
|
||||
className={cn("size-6 object-cover", props.iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<Blobbie address={props.address} size={24} />
|
||||
<Blobbie
|
||||
address={props.address}
|
||||
size={24}
|
||||
className={props.iconClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ScrollShadow } from "./ScrollShadow/ScrollShadow";
|
||||
import { Button } from "./button";
|
||||
import { ToolTipLabel } from "./tooltip";
|
||||
|
||||
export type TabLink = {
|
||||
name: string;
|
||||
@@ -82,6 +83,7 @@ export function TabButtons(props: {
|
||||
isActive: boolean;
|
||||
isDisabled?: boolean;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
toolTip?: string;
|
||||
}[];
|
||||
tabClassName?: string;
|
||||
activeTabClassName?: string;
|
||||
@@ -111,26 +113,31 @@ export function TabButtons(props: {
|
||||
>
|
||||
{props.tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: tabs don't change order, so index is stable
|
||||
key={index}
|
||||
variant="ghost"
|
||||
ref={tab.isActive ? activeTabRef : undefined}
|
||||
className={cn(
|
||||
"relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2 font-medium text-sm hover:bg-accent lg:px-3 lg:text-base",
|
||||
!tab.isActive &&
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
tab.isDisabled && "cursor-not-allowed opacity-50",
|
||||
props.tabClassName,
|
||||
tab.isActive && props.activeTabClassName,
|
||||
)}
|
||||
onClick={!tab.isDisabled ? tab.onClick : undefined}
|
||||
<ToolTipLabel
|
||||
key={typeof tab.name === "string" ? tab.name : index}
|
||||
label={tab.toolTip}
|
||||
>
|
||||
{tab.icon && (
|
||||
<tab.icon className={cn("size-6", props.tabIconClassName)} />
|
||||
)}
|
||||
{tab.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
ref={tab.isActive ? activeTabRef : undefined}
|
||||
className={cn(
|
||||
"relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2 font-medium text-sm hover:bg-accent lg:px-3 lg:text-base",
|
||||
!tab.isActive &&
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
tab.isDisabled && "cursor-not-allowed opacity-50",
|
||||
props.tabClassName,
|
||||
tab.isActive && props.activeTabClassName,
|
||||
)}
|
||||
onClick={!tab.isDisabled ? tab.onClick : undefined}
|
||||
>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={cn("size-6", props.tabIconClassName)}
|
||||
/>
|
||||
)}
|
||||
{tab.name}
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
THIRDWEB_BUNDLER_DOMAIN,
|
||||
THIRDWEB_INAPP_WALLET_DOMAIN,
|
||||
THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
THIRDWEB_PAY_DOMAIN,
|
||||
THIRDWEB_RPC_DOMAIN,
|
||||
THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
@@ -32,6 +33,7 @@ export function getThirdwebClient(jwt?: string) {
|
||||
storage: THIRDWEB_STORAGE_DOMAIN,
|
||||
social: THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
bundler: THIRDWEB_BUNDLER_DOMAIN,
|
||||
insight: THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +1,64 @@
|
||||
"use client";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Suspense,
|
||||
type TransitionStartFunction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { createStore } from "./reactive";
|
||||
|
||||
// Using useDashboardRouter instead of useRouter gives us a nice progress bar on top of the page when navigating using router.push or router.replace
|
||||
|
||||
// using a store instead of context to avoid triggering re-renders on root component
|
||||
const LoadingRouteHref = createStore<string | undefined>(undefined);
|
||||
const RouteStartTransitionFn = createStore<TransitionStartFunction>((fn) =>
|
||||
fn(),
|
||||
);
|
||||
|
||||
export function useDashboardRouter() {
|
||||
const startTransition = useSyncExternalStore(
|
||||
RouteStartTransitionFn.subscribe,
|
||||
RouteStartTransitionFn.getValue,
|
||||
RouteStartTransitionFn.getValue,
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
return useMemo(() => {
|
||||
return {
|
||||
...router,
|
||||
replace(href: string, options?: { scroll?: boolean }) {
|
||||
LoadingRouteHref.setValue(href);
|
||||
router.replace(href, options);
|
||||
startTransition(() => {
|
||||
router.replace(href, options);
|
||||
});
|
||||
},
|
||||
push(href: string, options?: { scroll?: boolean }) {
|
||||
LoadingRouteHref.setValue(href);
|
||||
router.push(href, options);
|
||||
startTransition(() => {
|
||||
router.push(href, options);
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [router]);
|
||||
}
|
||||
|
||||
function useRouterLoadingStatus() {
|
||||
const loadingHref = useSyncExternalStore(
|
||||
LoadingRouteHref.subscribe,
|
||||
LoadingRouteHref.getValue,
|
||||
LoadingRouteHref.getValue,
|
||||
);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const searchParamsStr = searchParams?.toString();
|
||||
|
||||
const routerHref = pathname + (searchParamsStr ? `?${searchParamsStr}` : "");
|
||||
const isLoading = loadingHref && loadingHref !== routerHref;
|
||||
|
||||
// reset loading on route load
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
LoadingRouteHref.setValue(undefined);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
return isLoading;
|
||||
}, [router, startTransition]);
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Render this on root
|
||||
function DashboardRouterTopProgressBarInner() {
|
||||
const isLoading = useRouterLoadingStatus();
|
||||
const [progress, setProgress] = useState(0);
|
||||
export function DashboardRouterTopProgressBar() {
|
||||
const [isRouteLoading, startTransition] = useTransition();
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
RouteStartTransitionFn.setValue(startTransition);
|
||||
}, []);
|
||||
|
||||
const [progress, setProgress] = useState(0);
|
||||
const progressStartedRef = useRef(false);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (!isRouteLoading) {
|
||||
setProgress(0);
|
||||
progressStartedRef.current = false;
|
||||
return;
|
||||
@@ -85,7 +74,7 @@ function DashboardRouterTopProgressBarInner() {
|
||||
|
||||
async function updateProgressBar(progress: number, delay: number) {
|
||||
if (!isMounted) {
|
||||
if (!isLoading) {
|
||||
if (!isRouteLoading) {
|
||||
setProgress(100);
|
||||
}
|
||||
return;
|
||||
@@ -112,14 +101,14 @@ function DashboardRouterTopProgressBarInner() {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isLoading]);
|
||||
}, [isRouteLoading]);
|
||||
|
||||
const width = isLoading ? progress : 100;
|
||||
const width = isRouteLoading ? progress : 100;
|
||||
return (
|
||||
<span
|
||||
className="fixed top-0 block h-[3px] bg-foreground"
|
||||
style={{
|
||||
opacity: isLoading ? "100" : "0",
|
||||
opacity: isRouteLoading ? "100" : "0",
|
||||
width: `${width}%`,
|
||||
transition: width === 0 ? "none" : "width 0.2s ease, opacity 0.3s ease",
|
||||
zIndex: "100000000",
|
||||
@@ -128,12 +117,3 @@ function DashboardRouterTopProgressBarInner() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// need to wrap with suspense because of useSearchParams usage
|
||||
export function DashboardRouterTopProgressBar() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DashboardRouterTopProgressBarInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export type ProductSKU =
|
||||
| "plan:starter"
|
||||
| "plan:growth"
|
||||
| "plan:custom"
|
||||
| "plan:accelerate"
|
||||
| "plan:scale"
|
||||
| "product:ecosystem_wallets"
|
||||
| "product:engine_standard"
|
||||
| "product:engine_premium"
|
||||
|
||||
@@ -15,8 +15,6 @@ import invariant from "tiny-invariant";
|
||||
import type { EngineStatus } from "../../../app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table";
|
||||
import { engineKeys } from "../cache-keys";
|
||||
|
||||
export type EngineTier = "STARTER" | "PREMIUM" | "ENTERPRISE";
|
||||
|
||||
// Engine instances
|
||||
export type EngineInstance = {
|
||||
id: string;
|
||||
@@ -24,6 +22,8 @@ export type EngineInstance = {
|
||||
name: string;
|
||||
url: string;
|
||||
lastAccessedAt: string;
|
||||
isCloudHosted: boolean;
|
||||
isPlanEngine: boolean;
|
||||
status:
|
||||
| "active"
|
||||
| "pending"
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { getSDKTheme } from "app/components/sdk-component-theme";
|
||||
import { useV5DashboardChain } from "lib/v5-adapter";
|
||||
import { useTheme } from "next-themes";
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
|
||||
export function UniversalBridgeEmbed({ chainId }: { chainId?: number }) {
|
||||
const { theme } = useTheme();
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<PayEmbed
|
||||
client={getThirdwebClient()}
|
||||
payOptions={{
|
||||
mode: "fund_wallet",
|
||||
prefillBuy: {
|
||||
chain,
|
||||
amount: "0.01",
|
||||
},
|
||||
}}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
|
||||
const title = "Universal Bridge: Swap, Bridge, and On-Ramp";
|
||||
const description =
|
||||
"Swap, bridge, and on-ramp to any EVM chain with thirdweb's Universal Bridge.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RoutesPage({
|
||||
searchParams,
|
||||
}: { searchParams: Record<string, string | string[]> }) {
|
||||
const { chainId } = searchParams;
|
||||
return (
|
||||
<div className="relative mx-auto flex h-screen w-full flex-col items-center justify-center overflow-hidden border py-10">
|
||||
<main className="container z-10 flex justify-center">
|
||||
<UniversalBridgeEmbed chainId={chainId ? Number(chainId) : undefined} />
|
||||
</main>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
src="/assets/login/background.svg"
|
||||
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { SectionTitle } from "../server/SectionTitle";
|
||||
|
||||
export default function NextSteps(props: { chain: ChainMetadata }) {
|
||||
const { chain } = props;
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Next Steps" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div className="relative flex gap-3 rounded-lg border bg-card p-4 pr-8 transition-colors hover:border-active-border">
|
||||
<FileText className="mt-0.5 size-5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="mb-1.5 font-medium">
|
||||
<Link
|
||||
href={
|
||||
"https://blog.thirdweb.com/supercharge-user-adoption-integrate-embedded-wallets-in-minutes/"
|
||||
}
|
||||
className="before:absolute before:inset-0"
|
||||
target="_blank"
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: "nextSteps",
|
||||
action: "click-inapp",
|
||||
label: "success",
|
||||
chain_id: chain.chainId,
|
||||
})
|
||||
}
|
||||
>
|
||||
Create a login for {chain.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Supercharge User Adoption—Integrate In-App Wallets in Minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -11,7 +11,7 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Buy Funds" />
|
||||
<SectionTitle title="Bridge" />
|
||||
<div className="flex justify-center rounded-lg border border-border bg-card px-4 py-10">
|
||||
<div className="flex max-w-[520px] flex-col items-center ">
|
||||
<div className="flex items-center">
|
||||
@@ -28,21 +28,21 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
<div className="h-6" />
|
||||
|
||||
<h2 className="px-4 text-center font-semibold text-lg tracking-tight">
|
||||
Buy Funds on {sanitizedChainName} using Universal Bridge
|
||||
Bridge Funds to {sanitizedChainName}
|
||||
</h2>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<p className="max-w-[520px] px-4 text-center text-muted-foreground text-sm">
|
||||
Instantly onramp, bridge & swap into {sanitizedChainName} with any
|
||||
token.
|
||||
Get {props.chain.nativeCurrency.symbol} on {sanitizedChainName} with
|
||||
fiat or any token on another chain.
|
||||
</p>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<PayModalButton
|
||||
chainId={props.chain.chainId}
|
||||
label={`Buy Funds on ${sanitizedChainName}`}
|
||||
label={`Bridge to ${sanitizedChainName}`}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
@@ -13,12 +13,18 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { ChevronDownIcon, TicketCheckIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../contexts/map-chains";
|
||||
import { getAuthToken } from "../../../../api/lib/getAuthToken";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../../api/lib/getAuthToken";
|
||||
import { NebulaFloatingChatButton } from "../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
|
||||
@@ -55,17 +61,58 @@ export default async function ChainPageLayout(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const authToken = await getAuthToken();
|
||||
const [chain, authToken, account, accountAddress] = await Promise.all([
|
||||
getChain(params.chain_id),
|
||||
getAuthToken(),
|
||||
getRawAccount(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
if (params.chain_id !== chain.slug) {
|
||||
redirect(chain.slug);
|
||||
}
|
||||
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const client = getThirdwebClient(authToken ?? undefined);
|
||||
|
||||
const chainPromptPrefix = `\
|
||||
You are assisting users exploring the chain ${chain.name} (Chain ID: ${chain.chainId}). Provide concise insights into the types of applications and activities prevalent on this chain, such as DeFi protocols, NFT marketplaces, or gaming platforms. Highlight notable projects or trends without delving into technical details like consensus mechanisms or gas fees.
|
||||
Users may seek comparisons between ${chain.name} and other chains. Provide objective, succinct comparisons focusing on performance, fees, and ecosystem support. Refrain from transaction-specific advice unless requested.
|
||||
Provide users with an understanding of the unique use cases and functionalities that ${chain.name} supports. Discuss how developers leverage this chain for specific applications, such as scalable dApps, low-cost transactions, or specialized token standards, focusing on practical implementations.
|
||||
Users may be interested in utilizing thirdweb tools on ${chain.name}. Offer clear guidance on how thirdweb's SDKs, smart contract templates, and deployment tools integrate with this chain. Emphasize the functionalities enabled by thirdweb without discussing transaction execution unless prompted.
|
||||
Avoid transaction-related actions to be executed by the user unless inquired about.
|
||||
|
||||
The following is the user's message:
|
||||
`;
|
||||
|
||||
const examplePrompts: string[] = [
|
||||
"What are users doing on this chain?",
|
||||
"What are the most active contracts?",
|
||||
"Why would I use this chain over others?",
|
||||
"Can I deploy thirdweb contracts to this chain?",
|
||||
];
|
||||
|
||||
if (chain.chainId !== 1) {
|
||||
examplePrompts.push("Can I bridge assets from Ethereum to this chain?");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NebulaFloatingChatButton
|
||||
authToken={authToken ?? undefined}
|
||||
account={account}
|
||||
label="Ask AI about this chain"
|
||||
client={client}
|
||||
nebulaParams={{
|
||||
messagePrefix: chainPromptPrefix,
|
||||
chainIds: [chain.chainId],
|
||||
wallet: accountAddress ?? undefined,
|
||||
}}
|
||||
examplePrompts={examplePrompts.map((prompt) => ({
|
||||
title: prompt,
|
||||
message: prompt,
|
||||
}))}
|
||||
/>
|
||||
<div className="flex h-14 border-border border-b pl-7">
|
||||
<Breadcrumb className="my-auto">
|
||||
<BreadcrumbList>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import NextSteps from "./components/client/NextSteps";
|
||||
import { BuyFundsSection } from "./components/server/BuyFundsSection";
|
||||
import { ChainOverviewSection } from "./components/server/ChainOverviewSection";
|
||||
import { ClaimChainSection } from "./components/server/ClaimChainSection";
|
||||
@@ -57,6 +58,8 @@ export default async function Page(props: {
|
||||
{chain.services.filter((s) => s.enabled).length > 0 && (
|
||||
<SupportedProductsSection services={chain.services} />
|
||||
)}
|
||||
{/*Next Steps */}
|
||||
<NextSteps chain={chain} />
|
||||
|
||||
{/* Claim Chain */}
|
||||
{!chainMetadata && <ClaimChainSection />}
|
||||
|
||||
+4
@@ -205,6 +205,8 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
owner: nft.owner,
|
||||
type: "ERC721",
|
||||
tokenURI: nft.tokenURI,
|
||||
chainId: nft.chainId,
|
||||
tokenAddress: nft.tokenAddress,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -216,6 +218,8 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
owner: nft.owner,
|
||||
type: "ERC1155",
|
||||
tokenURI: nft.tokenURI,
|
||||
chainId: nft.chainId,
|
||||
tokenAddress: nft.tokenAddress,
|
||||
};
|
||||
}) as WalletNFT[];
|
||||
}, [ownedNFTs, form]);
|
||||
|
||||
+23
-6
@@ -10,10 +10,11 @@ import type {
|
||||
DirectListing,
|
||||
EnglishAuction,
|
||||
} from "thirdweb/extensions/marketplace";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { BuyDirectListingButton, useActiveAccount } from "thirdweb/react";
|
||||
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { toast } from "sonner";
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
import { CancelTab } from "./cancel-tab";
|
||||
import { LISTING_STATUS } from "./types";
|
||||
@@ -141,11 +142,6 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
</GridItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Todo: Add a Buy button somewhere in this section once the Dashboard is fully migrated to v5 (?)
|
||||
Kien already shipped a prebuilt component for the Marketplace Buy Button in SDK v5
|
||||
*/}
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
{data?.asset.metadata.properties ? (
|
||||
@@ -168,6 +164,27 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
twAccount={twAccount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOwner && renderData.status === "ACTIVE" && (
|
||||
<BuyDirectListingButton
|
||||
listingId={renderData.id}
|
||||
contractAddress={renderData.assetContractAddress}
|
||||
chain={contract.chain}
|
||||
client={contract.client}
|
||||
className="w-full"
|
||||
quantity={1n}
|
||||
onError={(error) => {
|
||||
toast.error("Failed to buy listing", {
|
||||
description: error.message,
|
||||
});
|
||||
}}
|
||||
onTransactionConfirmed={() => {
|
||||
toast.success("Listing bought successfully");
|
||||
}}
|
||||
>
|
||||
Buy Listing
|
||||
</BuyDirectListingButton>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ export const ContractDirectListingsPage: React.FC<
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p className="text-lg">Contract Listings</p>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Direct Listings
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<CreateListingButton
|
||||
isInsightSupported={isInsightSupported}
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ export const ContractEnglishAuctionsPage: React.FC<
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p className="text-lg">Contract Auctions</p>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
English Auctions
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<CreateListingButton
|
||||
contract={contract}
|
||||
|
||||
+70
-54
@@ -1,14 +1,7 @@
|
||||
import {
|
||||
Flex,
|
||||
GridItem,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useMemo } from "react";
|
||||
import { type NFT, ZERO_ADDRESS } from "thirdweb";
|
||||
import { Card, TrackedLink, type TrackedLinkProps } from "tw-components";
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
|
||||
type NFTWithContract = NFT & { contractAddress: string; chainId: number };
|
||||
@@ -20,18 +13,20 @@ const dummyMetadata: (idx: number) => NFTWithContract = (idx) => ({
|
||||
tokenURI: `1-0x123-${idx}`,
|
||||
metadata: {
|
||||
name: "Loading...",
|
||||
description: "lorem ipsum loading sit amet",
|
||||
description:
|
||||
"lorem ipsum loading sit amet consectetur adipisicing elit. Quisquam, quos.",
|
||||
id: BigInt(idx || 0),
|
||||
uri: `1-0x123-${idx}`,
|
||||
},
|
||||
owner: `0x_fake_${idx}`,
|
||||
type: "ERC721",
|
||||
supply: 1n,
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
});
|
||||
|
||||
interface NFTCardsProps {
|
||||
nfts: Array<NFTWithContract>;
|
||||
trackingCategory: TrackedLinkProps["category"];
|
||||
trackingCategory: string;
|
||||
isPending: boolean;
|
||||
allNfts?: boolean;
|
||||
}
|
||||
@@ -42,61 +37,82 @@ export const NFTCards: React.FC<NFTCardsProps> = ({
|
||||
isPending,
|
||||
allNfts,
|
||||
}) => {
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
const dummyData = useMemo(() => {
|
||||
return Array.from({
|
||||
length: allNfts ? nfts.length : isMobile ? 2 : 3,
|
||||
length: allNfts ? nfts.length : 3,
|
||||
}).map((_, idx) => dummyMetadata(idx));
|
||||
}, [nfts.length, isMobile, allNfts]);
|
||||
}, [nfts.length, allNfts]);
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
gap={{ base: 3, md: 6 }}
|
||||
columns={allNfts ? { base: 2, md: 4 } : { base: 2, md: 3 }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{(isPending ? dummyData : nfts).map((token) => {
|
||||
const tokenId = token.id.toString();
|
||||
|
||||
return (
|
||||
<GridItem
|
||||
<div
|
||||
key={`${token.chainId}_${token.contractAddress}_${tokenId}`}
|
||||
as={TrackedLink}
|
||||
category={trackingCategory}
|
||||
href={`/${token.chainId}/${token.contractAddress}/nfts/${tokenId}`}
|
||||
_hover={{ opacity: 0.75, textDecoration: "none" }}
|
||||
className="hover:-translate-y-0.5 relative flex h-full cursor-pointer flex-col overflow-hidden rounded-lg bg-background duration-200 hover:scale-[1.01]"
|
||||
>
|
||||
<Card p={0} h="full">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-xl">
|
||||
<Skeleton isLoaded={!isPending}>
|
||||
<NFTMediaWithEmptyState
|
||||
metadata={token.metadata}
|
||||
requireInteraction
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</Skeleton>
|
||||
</div>
|
||||
<Flex p={4} pb={3} gap={1} direction="column">
|
||||
<Skeleton w={!isPending ? "100%" : "50%"} isLoaded={!isPending}>
|
||||
<h2 className="font-semibold tracking-tight">
|
||||
{token.metadata.name}
|
||||
</h2>
|
||||
</Skeleton>
|
||||
<SkeletonText isLoaded={!isPending}>
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm">
|
||||
{token.metadata.description ? (
|
||||
token.metadata.description
|
||||
) : (
|
||||
<i>No description</i>
|
||||
)}
|
||||
</p>
|
||||
</SkeletonText>
|
||||
</Flex>
|
||||
</Card>
|
||||
</GridItem>
|
||||
{/* border */}
|
||||
<div className="absolute inset-0 rounded-lg border border-border" />
|
||||
|
||||
{/* image */}
|
||||
<div className="relative aspect-square w-full overflow-hidden">
|
||||
<SkeletonContainer
|
||||
skeletonData={token.metadata}
|
||||
loadedData={isPending ? undefined : token.metadata}
|
||||
className="h-full w-full rounded-lg"
|
||||
render={(v) => {
|
||||
return (
|
||||
<NFTMediaWithEmptyState
|
||||
metadata={v}
|
||||
requireInteraction
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* title */}
|
||||
<SkeletonContainer
|
||||
skeletonData={token.metadata}
|
||||
loadedData={isPending ? undefined : token.metadata}
|
||||
className="mb-2"
|
||||
render={(v) => {
|
||||
return (
|
||||
<h2 className="font-semibold tracking-tight">
|
||||
<TrackedLinkTW
|
||||
category={trackingCategory}
|
||||
label="view_nft"
|
||||
href={`/${token.chainId}/${token.contractAddress}/nfts/${tokenId}`}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
{v.name}
|
||||
</TrackedLinkTW>
|
||||
</h2>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<SkeletonContainer
|
||||
skeletonData={token.metadata}
|
||||
loadedData={isPending ? undefined : token.metadata}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm">
|
||||
{v.description ? v.description : <i>No description</i>}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+7
-3
@@ -55,7 +55,7 @@ type Options =
|
||||
};
|
||||
|
||||
export async function getClaimPhasesInLegacyFormat(
|
||||
options: BaseTransactionOptions<Options>,
|
||||
options: BaseTransactionOptions<Options> & { isMultiPhase: boolean },
|
||||
): Promise<CombinedClaimCondition[]> {
|
||||
const conditions = await (async () => {
|
||||
switch (options.type) {
|
||||
@@ -64,7 +64,10 @@ export async function getClaimPhasesInLegacyFormat(
|
||||
case "erc721":
|
||||
return ERC721Ext.getClaimConditions(options);
|
||||
case "erc1155":
|
||||
return ERC1155Ext.getClaimConditions(options);
|
||||
return ERC1155Ext.getClaimConditions({
|
||||
...options,
|
||||
singlePhaseDrop: !options.isMultiPhase,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -126,7 +129,7 @@ export async function getClaimPhasesInLegacyFormat(
|
||||
type PhaseInput = z.input<typeof LegacyClaimConditionInputSchema>;
|
||||
|
||||
export function setClaimPhasesTx(
|
||||
baseOptions: BaseTransactionOptions<Options>,
|
||||
baseOptions: BaseTransactionOptions<Options> & { isSinglePhase?: boolean },
|
||||
rawPhases: PhaseInput[],
|
||||
) {
|
||||
const phases = rawPhases.map((phase) => {
|
||||
@@ -172,6 +175,7 @@ export function setClaimPhasesTx(
|
||||
contract: baseOptions.contract,
|
||||
phases,
|
||||
tokenId: baseOptions.tokenId,
|
||||
singlePhaseDrop: baseOptions.isSinglePhase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -222,11 +222,15 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
|
||||
const claimConditionsQuery = useReadContract(getClaimPhasesInLegacyFormat, {
|
||||
contract,
|
||||
isMultiPhase,
|
||||
...(isErc20
|
||||
? { type: "erc20", decimals: tokenDecimals.data }
|
||||
: isErc721
|
||||
? { type: "erc721" }
|
||||
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
|
||||
: {
|
||||
type: "erc1155",
|
||||
tokenId: BigInt(tokenId || 0),
|
||||
}),
|
||||
});
|
||||
|
||||
const transformedQueryData = useMemo(() => {
|
||||
@@ -370,6 +374,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
const tx = setClaimPhasesTx(
|
||||
{
|
||||
contract,
|
||||
isSinglePhase: !isMultiPhase,
|
||||
...(isErc20
|
||||
? { type: "erc20", decimals: tokenDecimals.data }
|
||||
: isErc721
|
||||
@@ -635,6 +640,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
isErc20={isErc20}
|
||||
contract={contract}
|
||||
tokenId={tokenId}
|
||||
isMultiphase={isMultiPhase}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ interface ClaimConditionsProps {
|
||||
isColumn?: true;
|
||||
isERC20: boolean;
|
||||
twAccount: Account | undefined;
|
||||
isMultiphase: boolean;
|
||||
}
|
||||
export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
|
||||
contract,
|
||||
@@ -17,6 +18,7 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
|
||||
isColumn,
|
||||
isERC20,
|
||||
twAccount,
|
||||
isMultiphase,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
@@ -37,8 +39,7 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
|
||||
contract={contract}
|
||||
tokenId={tokenId}
|
||||
isColumn={isColumn}
|
||||
// always multi phase!
|
||||
isMultiPhase={true}
|
||||
isMultiPhase={isMultiphase}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ interface ResetClaimEligibilityProps {
|
||||
contract: ThirdwebContract;
|
||||
tokenId?: string;
|
||||
twAccount: Account | undefined;
|
||||
isMultiphase: boolean;
|
||||
}
|
||||
|
||||
export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
|
||||
@@ -25,6 +26,7 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
|
||||
tokenId,
|
||||
isErc20,
|
||||
twAccount,
|
||||
isMultiphase,
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
|
||||
@@ -55,6 +57,7 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
|
||||
return ERC1155Ext.resetClaimEligibility({
|
||||
contract,
|
||||
tokenId: BigInt(tokenId),
|
||||
singlePhaseDrop: !isMultiphase,
|
||||
});
|
||||
}
|
||||
// assume erc 721
|
||||
|
||||
+26
-25
@@ -36,33 +36,34 @@ export function ContractPageLayout(props: {
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<SidebarLayout sidebarLinks={sidebarLinks}>
|
||||
<div className="border-border border-b pb-8">
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row">
|
||||
<ContractMetadata
|
||||
contract={contract}
|
||||
chain={chainMetadata}
|
||||
contractMetadata={dashboardContractMetadata}
|
||||
externalLinks={externalLinks}
|
||||
/>
|
||||
<PrimaryDashboardButton
|
||||
contractAddress={contract.address}
|
||||
chain={contract.chain}
|
||||
contractInfo={{
|
||||
chain: chainMetadata,
|
||||
chainSlug: chainMetadata.slug,
|
||||
contractAddress: contract.address,
|
||||
}}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
<DeprecatedAlert chain={chainMetadata} />
|
||||
<div className="border-border border-b py-8">
|
||||
<div className="container flex flex-col gap-4">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row">
|
||||
<ContractMetadata
|
||||
contract={contract}
|
||||
chain={chainMetadata}
|
||||
contractMetadata={dashboardContractMetadata}
|
||||
externalLinks={externalLinks}
|
||||
/>
|
||||
<PrimaryDashboardButton
|
||||
contractAddress={contract.address}
|
||||
chain={contract.chain}
|
||||
contractInfo={{
|
||||
chain: chainMetadata,
|
||||
chainSlug: chainMetadata.slug,
|
||||
contractAddress: contract.address,
|
||||
}}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
<DeprecatedAlert chain={chainMetadata} />
|
||||
</div>
|
||||
<div className="h-8" />
|
||||
<div className="pb-10">{props.children}</div>
|
||||
</div>
|
||||
|
||||
<SidebarLayout sidebarLinks={sidebarLinks}>
|
||||
{props.children}
|
||||
<div className="h-20" />
|
||||
</SidebarLayout>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
@@ -66,7 +66,7 @@ export const MetadataHeader: React.FC<MetadataHeaderProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-3">
|
||||
{/* Title */}
|
||||
{!data ? (
|
||||
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
@@ -75,7 +75,7 @@ export const MetadataHeader: React.FC<MetadataHeaderProps> = ({
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
{data?.name && (
|
||||
<h1 className="line-clamp-2 font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
<h1 className="line-clamp-2 font-bold text-2xl tracking-tight lg:text-3xl">
|
||||
{data.name}
|
||||
</h1>
|
||||
)}
|
||||
@@ -85,7 +85,7 @@ export const MetadataHeader: React.FC<MetadataHeaderProps> = ({
|
||||
href={`/${chain.slug}`}
|
||||
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
|
||||
>
|
||||
<ChainIcon ipfsSrc={chain.icon?.url} className="size-4" />
|
||||
<ChainIconClient ipfsSrc={chain.icon?.url} className="size-4" />
|
||||
{cleanedChainName && (
|
||||
<span className="text-xs">{cleanedChainName}</span>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ function AddToProjectButton(props: {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" variant="outline">
|
||||
<Button className="gap-2 bg-card" variant="outline">
|
||||
<PlusIcon className="size-4" />
|
||||
Add To Project
|
||||
</Button>
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ export const NftsOwned: React.FC<NftsOwnedProps> = ({
|
||||
type: nft.type,
|
||||
contractAddress: nft.contractAddress,
|
||||
chainId: contract.chain.id,
|
||||
tokenAddress: nft.tokenAddress,
|
||||
}))}
|
||||
allNfts
|
||||
isPending={isWalletNFTsLoading}
|
||||
|
||||
+52
-38
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { Skeleton, Stat, StatLabel, StatNumber } from "@chakra-ui/react";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
type AnalyticsQueryParams,
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
useTotalContractTransactionAnalytics,
|
||||
useTotalContractUniqueWallets,
|
||||
} from "data/analytics/hooks";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { Card } from "tw-components";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
type Range,
|
||||
@@ -121,6 +121,15 @@ type ChartProps = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
function toolTipLabelFormatter(_v: string, item: unknown) {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function UniqueWalletsChart(props: ChartProps) {
|
||||
const analyticsQuery = useContractUniqueWalletAnalytics(props);
|
||||
|
||||
@@ -128,6 +137,7 @@ function UniqueWalletsChart(props: ChartProps) {
|
||||
<ThirdwebBarChart
|
||||
header={{
|
||||
title: "Unique Wallets",
|
||||
titleClassName: "mb-0.5 text-xl",
|
||||
description:
|
||||
"The number of unique wallet addresses that have sent a transaction to this contract.",
|
||||
}}
|
||||
@@ -140,6 +150,8 @@ function UniqueWalletsChart(props: ChartProps) {
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={toolTipLabelFormatter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -156,6 +168,7 @@ function TotalTransactionsChart(props: ChartProps) {
|
||||
<ThirdwebBarChart
|
||||
header={{
|
||||
title: "Total Transactions",
|
||||
titleClassName: "mb-0.5 text-xl",
|
||||
description:
|
||||
"The number of transactions that have been sent to this contract.",
|
||||
}}
|
||||
@@ -168,6 +181,8 @@ function TotalTransactionsChart(props: ChartProps) {
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={toolTipLabelFormatter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -179,6 +194,7 @@ function TotalEventsChart(props: ChartProps) {
|
||||
<ThirdwebBarChart
|
||||
header={{
|
||||
title: "Total Events",
|
||||
titleClassName: "mb-0.5 text-xl",
|
||||
description:
|
||||
"The number of on-chain events that have been emitted from this contract.",
|
||||
}}
|
||||
@@ -191,6 +207,8 @@ function TotalEventsChart(props: ChartProps) {
|
||||
},
|
||||
}}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={toolTipLabelFormatter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -227,6 +245,7 @@ function FunctionBreakdownChart(
|
||||
<ThirdwebBarChart
|
||||
header={{
|
||||
title: "Function Breakdown",
|
||||
titleClassName: "mb-0.5 text-xl",
|
||||
description:
|
||||
"The breakdown of calls to each write function from transactions.",
|
||||
}}
|
||||
@@ -247,6 +266,8 @@ function FunctionBreakdownChart(
|
||||
)}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
showLegend
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={toolTipLabelFormatter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +304,7 @@ function EventBreakdownChart(
|
||||
<ThirdwebBarChart
|
||||
header={{
|
||||
title: "Event Breakdown",
|
||||
titleClassName: "mb-0.5 text-xl",
|
||||
description: "The breakdown of events emitted by this contract.",
|
||||
}}
|
||||
data={mappedQueryData || []}
|
||||
@@ -302,6 +324,8 @@ function EventBreakdownChart(
|
||||
)}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
showLegend
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={toolTipLabelFormatter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -320,25 +344,12 @@ const AnalyticsStat: React.FC<AnalyticsStatProps> = ({
|
||||
useTotal,
|
||||
}) => {
|
||||
return (
|
||||
<Suspense fallback={<AnalyticsSkeleton label={label} />}>
|
||||
<AnalyticsData
|
||||
chainId={chainId}
|
||||
contractAddress={contractAddress}
|
||||
useTotal={useTotal}
|
||||
label={label}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const AnalyticsSkeleton: React.FC<{ label: string }> = ({ label }) => {
|
||||
return (
|
||||
<Card as={Stat} className="bg-card">
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>{label}</StatLabel>
|
||||
<Skeleton isLoaded={false}>
|
||||
<StatNumber>{0}</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
<AnalyticsData
|
||||
chainId={chainId}
|
||||
contractAddress={contractAddress}
|
||||
useTotal={useTotal}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -355,20 +366,23 @@ const AnalyticsData: React.FC<AnalyticsStatProps> = ({
|
||||
chainId,
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!totalQuery.data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return totalQuery.data.count;
|
||||
}, [totalQuery.data]);
|
||||
|
||||
return (
|
||||
<Card as={Stat} className="bg-card">
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>{label}</StatLabel>
|
||||
<Skeleton isLoaded={totalQuery.isFetched}>
|
||||
<StatNumber>{data.toLocaleString()}</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
);
|
||||
return <AnalyticsStatUI label={label} data={totalQuery.data?.count} />;
|
||||
};
|
||||
|
||||
function AnalyticsStatUI(props: {
|
||||
label: string;
|
||||
data: number | undefined;
|
||||
}) {
|
||||
return (
|
||||
<dl className="rounded-lg border bg-card p-4">
|
||||
<dt className="font-semibold">{props.label}</dt>
|
||||
<SkeletonContainer
|
||||
skeletonData={10000}
|
||||
loadedData={props.data}
|
||||
render={(v) => {
|
||||
return <dd className="font-normal text-xl">{v.toLocaleString()}</dd>;
|
||||
}}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ export function ClaimConditionsClient(props: {
|
||||
contract={props.contract}
|
||||
isERC20={supportedERCs.isERC20}
|
||||
twAccount={props.twAccount}
|
||||
isMultiphase={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ export default async function Page(props: {
|
||||
contract={contract}
|
||||
isERC20={supportedERCs.isERC20}
|
||||
twAccount={account}
|
||||
isMultiphase={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-12
@@ -2,7 +2,6 @@ import { fetchPublishedContractsFromDeploy } from "components/contract-component
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
type ContractOptions,
|
||||
eth_blockNumber,
|
||||
eth_getTransactionByHash,
|
||||
eth_getTransactionReceipt,
|
||||
getContractEvents,
|
||||
@@ -39,14 +38,17 @@ export default async function Page(props: {
|
||||
chain_id: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
const ProxyDeployedEvent = prepareEvent({
|
||||
signature:
|
||||
"event ProxyDeployedV2(address indexed implementation, address indexed proxy, address indexed deployer, bytes32 inputSalt, bytes data, bytes extraData)",
|
||||
filters: {
|
||||
proxy: params.contractAddress.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
@@ -111,16 +113,9 @@ export default async function Page(props: {
|
||||
let creationBlockNumber: bigint | undefined;
|
||||
|
||||
if (twCloneFactoryContract) {
|
||||
const latestBlockNumber = await eth_blockNumber(
|
||||
getRpcClient({
|
||||
client: contract.client,
|
||||
chain: contract.chain,
|
||||
}),
|
||||
);
|
||||
const events = await getContractEvents({
|
||||
contract: twCloneFactoryContract,
|
||||
events: [ProxyDeployedEvent],
|
||||
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
|
||||
});
|
||||
const event = events.find(
|
||||
(e) =>
|
||||
@@ -208,7 +203,6 @@ export default async function Page(props: {
|
||||
const events = await getContractEvents({
|
||||
contract: contract,
|
||||
events: [moduleEvent],
|
||||
blockRange: 123456n,
|
||||
});
|
||||
|
||||
const filteredEvents = events.filter(
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SingleNetworkSelector(props: {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
|
||||
@@ -9,6 +9,12 @@ import { isAddress, isContractDeployed } from "thirdweb/utils";
|
||||
import type { MinimalTeamsAndProjects } from "../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { resolveFunctionSelectors } from "../../../../../lib/selectors";
|
||||
import { shortenIfAddress } from "../../../../../utils/usedapp-external";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../../api/lib/getAuthToken";
|
||||
import { NebulaFloatingChatButton } from "../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
|
||||
import { ConfigureCustomChain } from "./_layout/ConfigureCustomChain";
|
||||
import { getContractMetadataHeaderData } from "./_layout/contract-metadata";
|
||||
import { ContractPageLayout } from "./_layout/contract-page-layout";
|
||||
@@ -42,6 +48,12 @@ export default async function Layout(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [authToken, account, accountAddress] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getRawAccount(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
const client = getThirdwebClient();
|
||||
const teamsAndProjects = await getTeamsAndProjectsIfLoggedIn();
|
||||
|
||||
@@ -75,6 +87,25 @@ export default async function Layout(props: {
|
||||
const { contractMetadata, externalLinks } =
|
||||
await getContractMetadataHeaderData(contract);
|
||||
|
||||
const contractAddress = info.contract.address;
|
||||
const chainName = info.chainMetadata.name;
|
||||
const chainId = info.contract.chain.id;
|
||||
|
||||
const contractPromptPrefix = `A user is viewing the contract address ${contractAddress} on ${chainName} (Chain ID: ${chainId}). Provide a concise summary of this contract's functionalities, such as token minting, staking, or governance mechanisms. Focus on what the contract enables users to do, avoiding transaction execution details unless requested.
|
||||
Users may be interested in how to interact with the contract. Outline common interaction patterns, such as claiming rewards, participating in governance, or transferring assets. Emphasize the contract's capabilities without guiding through transaction processes unless asked.
|
||||
Provide insights into how the contract is being used. Share information on user engagement, transaction volumes, or integration with other dApps, focusing on the contract's role within the broader ecosystem.
|
||||
Users may be considering integrating the contract into their applications. Discuss how this contract's functionalities can be leveraged within different types of dApps, highlighting potential use cases and benefits.
|
||||
|
||||
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}
|
||||
@@ -85,6 +116,21 @@ export default async function Layout(props: {
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
>
|
||||
<NebulaFloatingChatButton
|
||||
authToken={authToken ?? undefined}
|
||||
account={account}
|
||||
label="Ask AI about this contract"
|
||||
client={client}
|
||||
nebulaParams={{
|
||||
messagePrefix: contractPromptPrefix,
|
||||
chainIds: [info.contract.chain.id],
|
||||
wallet: accountAddress ?? undefined,
|
||||
}}
|
||||
examplePrompts={examplePrompts.map((prompt) => ({
|
||||
title: prompt,
|
||||
message: prompt,
|
||||
}))}
|
||||
/>
|
||||
{props.children}
|
||||
</ContractPageLayout>
|
||||
);
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export const ContractNFTPage: React.FC<NftOverviewPageProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Contract NFTs</h2>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">NFTs</h2>
|
||||
<div className="grid grid-cols-2 gap-2 md:flex">
|
||||
{isRevealable && (
|
||||
<NFTRevealButton contract={contract} twAccount={twAccount} />
|
||||
|
||||
+248
-234
@@ -4,33 +4,31 @@ import { UnexpectedValueErrorMessage } from "@/components/blocks/error-fallbacks
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
Box,
|
||||
ButtonGroup,
|
||||
Divider,
|
||||
Flex,
|
||||
GridItem,
|
||||
IconButton,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { useChainSlug } from "hooks/chains/chainSlug";
|
||||
import { ChevronLeftIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { NFT, ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import { getNFT as getErc721NFT } from "thirdweb/extensions/erc721";
|
||||
import { getNFT as getErc1155NFT } from "thirdweb/extensions/erc1155";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { Button, Card } from "tw-components";
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
import { NftProperty } from "../components/nft-property";
|
||||
import { useNFTDrawerTabs } from "./useNftDrawerTabs";
|
||||
|
||||
@@ -66,7 +64,6 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
twAccount,
|
||||
}) => {
|
||||
const [tab, setTab] = useState("Details");
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const router = useDashboardRouter();
|
||||
const chainId = contract.chain.id;
|
||||
const chainSlug = useChainSlug(chainId || 1);
|
||||
@@ -89,15 +86,6 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
const tokenURIHttpLink = resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: nft?.tokenURI,
|
||||
});
|
||||
const nftImageLink = resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: nft?.metadata.image,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
@@ -123,230 +111,218 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<div className="relative aspect-square w-full shrink-0 self-start overflow-hidden bg-card lg:w-[350px]">
|
||||
{/* border */}
|
||||
<div className="absolute inset-0 z-0 rounded-lg border" />
|
||||
{/* media */}
|
||||
<NFTMediaWithEmptyState metadata={nftMetadata} className="z-10" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* breadcrumb + title */}
|
||||
<div>
|
||||
<Breadcrumb className="mb-3">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
href={`/${chainSlug}/${contract.address}/nfts`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.push(`/${chainSlug}/${contract.address}/nfts`);
|
||||
}}
|
||||
>
|
||||
NFTs
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink>#{tokenId}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<NFTName value={nft.metadata.name} />
|
||||
{nft.metadata?.description && (
|
||||
<NFTDescription value={nft.metadata.description} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
{
|
||||
name: "Details",
|
||||
onClick: () => setTab("Details"),
|
||||
isActive: tab === "Details",
|
||||
isDisabled: false,
|
||||
},
|
||||
...tabs.map((tb) => ({
|
||||
name: tb.title,
|
||||
onClick: () => setTab(tb.title),
|
||||
isActive: tab === tb.title,
|
||||
isDisabled: tb.isDisabled,
|
||||
toolTip: tb.isDisabled ? tb.disabledText : undefined,
|
||||
})),
|
||||
].sort((a, b) => (a.isDisabled ? 1 : b.isDisabled ? -1 : 0))}
|
||||
/>
|
||||
|
||||
{/* tab contents */}
|
||||
{tab === "Details" && <NFTDetailsTab nft={nft} client={client} />}
|
||||
|
||||
{tabs.map((tb) => {
|
||||
return (
|
||||
tb.title === tab && (
|
||||
<div key={tb.title} className="w-full">
|
||||
{tb.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function NFTDetailsTab(props: {
|
||||
nft: NFT;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { nft, client } = props;
|
||||
const properties = nft.metadata.attributes || nft.metadata.properties;
|
||||
|
||||
return (
|
||||
<Flex flexDir={{ base: "column", lg: "row" }} gap={6}>
|
||||
<Card h="full" position="relative" minH="100px">
|
||||
<Box w="50px" position="absolute" zIndex={1000} top={6} left={6}>
|
||||
{/* TODO - replace this with breadcrumbs */}
|
||||
<Card p={1} bgColor="backgroundCardHighlight">
|
||||
<IconButton
|
||||
w="inherit"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.push(`/${chainSlug}/${contract.address}/nfts`)
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* common */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 items-center gap-x-8 gap-y-4 lg:grid-cols-[1fr_2fr]">
|
||||
{/* token id */}
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Token ID</p>
|
||||
<CopyTextButton
|
||||
textToCopy={nft.id?.toString()}
|
||||
textToShow={
|
||||
nft.id?.toString().length > 8
|
||||
? `${nft.id.toString().slice(0, 4)}...${nft.id.toString().slice(-4)}`
|
||||
: nft.id?.toString()
|
||||
}
|
||||
aria-label="Back"
|
||||
icon={<ChevronLeftIcon className="size-6" />}
|
||||
>
|
||||
Back
|
||||
</IconButton>
|
||||
</Card>
|
||||
</Box>
|
||||
<NFTMediaWithEmptyState
|
||||
metadata={nftMetadata}
|
||||
width={isMobile ? "100%" : "300px"}
|
||||
height={isMobile ? "100%" : "300px"}
|
||||
/>
|
||||
</Card>
|
||||
<Flex flexDir="column" gap={6} w="full" px={2}>
|
||||
<Flex flexDir="column" gap={1.5}>
|
||||
<NFTName value={nft.metadata.name} />
|
||||
{nft.metadata?.description && (
|
||||
<NFTDescription value={nft.metadata.description} />
|
||||
tooltip="Token ID"
|
||||
copyIconPosition="right"
|
||||
className="min-w-16 justify-between bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* owner */}
|
||||
{nft.owner && (
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Owner</p>
|
||||
<WalletAddress
|
||||
address={nft.owner}
|
||||
className="h-auto rounded-full border bg-card px-2 py-1 pr-4 text-foreground text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex flexDir="column" gap={{ base: 0, md: 4 }}>
|
||||
<Box
|
||||
w="full"
|
||||
overflow={{ base: "auto", md: "hidden" }}
|
||||
pb={{ base: 4, md: 0 }}
|
||||
>
|
||||
<ButtonGroup size="sm" variant="ghost" spacing={2}>
|
||||
<Button
|
||||
type="button"
|
||||
isActive={tab === "Details"}
|
||||
_active={{
|
||||
bg: "bgBlack",
|
||||
color: "bgWhite",
|
||||
}}
|
||||
rounded="lg"
|
||||
onClick={() => setTab("Details")}
|
||||
{/* type */}
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Token Standard</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-card py-1 font-normal text-sm"
|
||||
>
|
||||
{nft.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* supply */}
|
||||
{nft.type !== "ERC721" && (
|
||||
<div className="w-full">
|
||||
<p className="mb-1 text-muted-foreground text-sm">Supply</p>
|
||||
<ToolTipLabel
|
||||
label={nft.supply.toLocaleString("en-US")}
|
||||
contentClassName="text-sm break-all"
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
{tabs.map((tb) => (
|
||||
<Tooltip
|
||||
key={tb.title}
|
||||
p={0}
|
||||
bg="transparent"
|
||||
boxShadow="none"
|
||||
label={
|
||||
tb.isDisabled ? (
|
||||
<Card py={2} px={4} bgColor="backgroundHighlight">
|
||||
<p>{tb.disabledText}</p>
|
||||
</Card>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isDisabled={tb.isDisabled}
|
||||
type="button"
|
||||
isActive={tab === tb.title}
|
||||
_active={{
|
||||
bg: "bgBlack",
|
||||
color: "bgWhite",
|
||||
}}
|
||||
rounded="lg"
|
||||
onClick={() => setTab(tb.title)}
|
||||
>
|
||||
{tb.title}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
<Divider />
|
||||
</Flex>
|
||||
<p className="max-w-[200px] truncate text-sm">
|
||||
{nft.supply.toLocaleString("en-US")}
|
||||
</p>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "Details" && (
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Card as={Flex} flexDir="column" gap={3}>
|
||||
<SimpleGrid rowGap={3} columns={12} placeItems="center left">
|
||||
<GridItem colSpan={4}>
|
||||
<p className="font-semibold">Token ID</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={8}>
|
||||
<CopyTextButton
|
||||
textToCopy={nft.id?.toString()}
|
||||
textToShow={
|
||||
nft.id?.toString().length > 8
|
||||
? `${nft.id.toString().slice(0, 4)}...${nft.id.toString().slice(-4)}`
|
||||
: nft.id?.toString()
|
||||
}
|
||||
tooltip="Token ID"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
</GridItem>
|
||||
{/* token uri */}
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Token URI</p>
|
||||
<IPFSLinkGroup
|
||||
ipfsLink={nft.tokenURI}
|
||||
tooltip="The URI of this NFT"
|
||||
client={client}
|
||||
httpsLinkTooltip="View URI"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{nft.owner && (
|
||||
<>
|
||||
<GridItem colSpan={4}>
|
||||
<p className="font-semibold">Owner</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={8}>
|
||||
<WalletAddress address={nft.owner} />
|
||||
</GridItem>
|
||||
</>
|
||||
)}
|
||||
<GridItem colSpan={4}>
|
||||
<p className="font-semibold">Token Standard</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={8}>{nft.type}</GridItem>
|
||||
{nft.type !== "ERC721" && (
|
||||
<>
|
||||
<GridItem colSpan={4}>
|
||||
<p className="font-semibold">Supply</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={8}>
|
||||
<p>{nft.supply.toLocaleString("en-US")}</p>
|
||||
</GridItem>
|
||||
</>
|
||||
)}
|
||||
<GridItem colSpan={4}>
|
||||
<p className="font-semibold">Token URI</p>
|
||||
</GridItem>
|
||||
<GridItem
|
||||
colSpan={8}
|
||||
className="flex flex-row items-center gap-1"
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={nft.tokenURI}
|
||||
textToShow={shortenString(nft.tokenURI)}
|
||||
tooltip="The URI of this NFT"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
{tokenURIHttpLink && (
|
||||
<Button variant="ghost" size="sm">
|
||||
<Link href={tokenURIHttpLink} target="_blank">
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</GridItem>
|
||||
{nft.metadata.image && (
|
||||
<>
|
||||
<GridItem colSpan={4}>
|
||||
<p className="font-semibold">Media URI</p>
|
||||
</GridItem>
|
||||
<GridItem
|
||||
colSpan={8}
|
||||
className="flex flex-row items-center gap-1"
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={nft.metadata.image}
|
||||
textToShow={shortenString(nft.metadata.image)}
|
||||
tooltip="The media URI of this NFT"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
{nftImageLink && (
|
||||
<Button variant="ghost" size="sm">
|
||||
<Link href={nftImageLink} target="_blank">
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</GridItem>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
{properties ? (
|
||||
<Card as={Flex} flexDir="column" gap={4}>
|
||||
<p className="font-semibold">Attributes</p>
|
||||
{Array.isArray(properties) &&
|
||||
String(properties[0]?.value) !== "undefined" ? (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={2}>
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: FIXME */}
|
||||
{properties.map((property: any, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<NftProperty key={idx} property={property} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<CodeClient
|
||||
code={JSON.stringify(properties, null, 2) || ""}
|
||||
lang="json"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
</Flex>
|
||||
)}
|
||||
{tabs.map((tb) => {
|
||||
return (
|
||||
tb.title === tab && (
|
||||
<Flex key={tb.title} px={0} w="full">
|
||||
{tb.children}
|
||||
</Flex>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
{/* media uri */}
|
||||
{nft.metadata.image && (
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Media URI</p>
|
||||
<IPFSLinkGroup
|
||||
ipfsLink={nft.metadata.image}
|
||||
tooltip="The media URI of this NFT"
|
||||
client={client}
|
||||
httpsLinkTooltip="View Media"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* attributes */}
|
||||
{properties ? (
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-2 font-medium text-base">
|
||||
Attributes
|
||||
{Array.isArray(properties) && properties.length > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-card px-2 text-muted-foreground"
|
||||
>
|
||||
{properties.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
<div>
|
||||
{Array.isArray(properties) &&
|
||||
String(properties[0]?.value) !== "undefined" ? (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: FIXME */}
|
||||
{properties.map((property: any, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<NftProperty key={idx} property={property} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<CodeClient
|
||||
code={JSON.stringify(properties, null, 2) || ""}
|
||||
lang="json"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function NFTName(props: {
|
||||
value: unknown;
|
||||
}) {
|
||||
if (typeof props.value === "string") {
|
||||
return (
|
||||
<h2 className="font-semibold text-lg tracking-tight"> {props.value}</h2>
|
||||
<h2 className="font-bold text-3xl tracking-tight"> {props.value}</h2>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -360,11 +336,49 @@ function NFTName(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function IPFSLinkGroup(props: {
|
||||
ipfsLink: string;
|
||||
tooltip: string;
|
||||
client: ThirdwebClient;
|
||||
httpsLinkTooltip: string;
|
||||
}) {
|
||||
const httpLink = resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: props.ipfsLink,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
<CopyTextButton
|
||||
textToCopy={props.ipfsLink}
|
||||
textToShow={props.ipfsLink}
|
||||
tooltip={props.tooltip}
|
||||
copyIconPosition="right"
|
||||
className="max-w-[200px] truncate bg-card"
|
||||
/>
|
||||
{httpLink && (
|
||||
<ToolTipLabel label={props.httpsLinkTooltip}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="size-[34px] rounded-full bg-card p-0 text-muted-foreground"
|
||||
>
|
||||
<Link href={httpLink} target="_blank">
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NFTDescription(props: {
|
||||
value: unknown;
|
||||
}) {
|
||||
if (typeof props.value === "string") {
|
||||
return <p className="text-muted-foreground"> {props.value}</p>;
|
||||
return <p className="text-muted-foreground text-sm"> {props.value}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+5
-1
@@ -68,7 +68,6 @@ export function useNFTDrawerTabs({
|
||||
const hasERC1155ClaimConditions = (() => {
|
||||
return [
|
||||
// reads
|
||||
ERC1155Ext.isGetClaimConditionByIdSupported(functionSelectors),
|
||||
ERC1155Ext.isGetClaimConditionsSupported(functionSelectors),
|
||||
ERC1155Ext.isGetActiveClaimConditionSupported(functionSelectors),
|
||||
// writes
|
||||
@@ -77,6 +76,10 @@ export function useNFTDrawerTabs({
|
||||
].every(Boolean);
|
||||
})();
|
||||
|
||||
const isERC1155Multiphase = (() => {
|
||||
return ERC1155Ext.isGetClaimConditionByIdSupported(functionSelectors);
|
||||
})();
|
||||
|
||||
const isBurnable = (() => {
|
||||
if (isERC721) {
|
||||
return ERC721Ext.isBurnSupported(functionSelectors);
|
||||
@@ -130,6 +133,7 @@ export function useNFTDrawerTabs({
|
||||
tokenId={tokenId}
|
||||
isColumn
|
||||
twAccount={twAccount}
|
||||
isMultiphase={isERC1155Multiphase}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
+4
-6
@@ -1,5 +1,3 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface NftPropertyProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
property: any;
|
||||
@@ -7,17 +5,17 @@ interface NftPropertyProps {
|
||||
|
||||
export const NftProperty: React.FC<NftPropertyProps> = ({ property }) => {
|
||||
return (
|
||||
<Card className="flex flex-col gap-2 p-3">
|
||||
<div className="rounded-lg border bg-card p-3 text-sm">
|
||||
{property?.trait_type && (
|
||||
<p className="text-center text-link-foreground text-sm leading-[1.2]">
|
||||
<p className="mb-0.5 text-muted-foreground text-sm">
|
||||
{property?.trait_type}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
<p className="text-foreground text-sm">
|
||||
{typeof property?.value === "object"
|
||||
? JSON.stringify(property?.value || {})
|
||||
: property?.value}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export const SupplyCards: React.FC<SupplyCardsProps> = ({ contract }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-3 md:gap-6 [&>*]:grow">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<StatCard
|
||||
value={realTotalSupply.toString()}
|
||||
label="Total Supply"
|
||||
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { AnalyticsOverview } from "./components/Analytics";
|
||||
import { TokenDetailsCard } from "../tokens/components/supply";
|
||||
import { ContractAnalyticsOverviewCard } from "./components/Analytics";
|
||||
import { BuildYourApp } from "./components/BuildYourApp";
|
||||
import { ContractChecklist } from "./components/ContractChecklist";
|
||||
import { LatestEvents } from "./components/LatestEvents";
|
||||
import { MarketplaceDetails } from "./components/MarketplaceDetails";
|
||||
import { NFTDetails } from "./components/NFTDetails";
|
||||
import { PermissionsTable } from "./components/PermissionsTable";
|
||||
import { TokenDetails } from "./components/TokenDetails";
|
||||
|
||||
interface ContractOverviewPageProps {
|
||||
contract: ThirdwebContract;
|
||||
@@ -38,8 +38,8 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
publishedBy,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 lg:flex-row">
|
||||
<div className="flex flex-col gap-16">
|
||||
<div className="flex flex-col gap-10 lg:flex-row lg:gap-8">
|
||||
<div className="flex grow flex-col gap-10 overflow-hidden">
|
||||
<ContractChecklist
|
||||
isErc721={isErc721}
|
||||
isErc1155={isErc1155}
|
||||
@@ -50,7 +50,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
/>
|
||||
|
||||
{isAnalyticsSupported && (
|
||||
<AnalyticsOverview
|
||||
<ContractAnalyticsOverviewCard
|
||||
contractAddress={contract.address}
|
||||
chainId={contract.chain.id}
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
@@ -77,7 +77,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isErc20 && <TokenDetails contract={contract} />}
|
||||
{isErc20 && <TokenDetailsCard contract={contract} />}
|
||||
|
||||
<LatestEvents
|
||||
contract={contract}
|
||||
|
||||
+52
-58
@@ -12,19 +12,16 @@ import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface AnalyticsOverviewProps {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
trackingCategory: string;
|
||||
chainSlug: string;
|
||||
function getDayKey(date: Date) {
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
chainId,
|
||||
contractAddress,
|
||||
trackingCategory,
|
||||
chainSlug,
|
||||
}) => {
|
||||
export function ContractAnalyticsOverviewCard(props: {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
trackingCategory: string;
|
||||
chainSlug: string;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
const [startDate] = useState(
|
||||
(() => {
|
||||
@@ -35,55 +32,27 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
|
||||
);
|
||||
const [endDate] = useState(new Date());
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
|
||||
<Button
|
||||
asChild
|
||||
className="gap-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: trackingCategory,
|
||||
action: "click",
|
||||
label: "view_all_analytics",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link href={`/${chainSlug}/${contractAddress}/analytics`}>
|
||||
<span>View All</span>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
const wallets = useContractUniqueWalletAnalytics({
|
||||
chainId: props.chainId,
|
||||
contractAddress: props.contractAddress,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
<OverviewAnalytics
|
||||
chainId={chainId}
|
||||
contractAddress={contractAddress}
|
||||
endDate={endDate}
|
||||
startDate={startDate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const transactions = useContractTransactionAnalytics({
|
||||
chainId: props.chainId,
|
||||
contractAddress: props.contractAddress,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
type ChartProps = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
const events = useContractEventAnalytics({
|
||||
chainId: props.chainId,
|
||||
contractAddress: props.contractAddress,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
function getDayKey(date: Date) {
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
function OverviewAnalytics(props: ChartProps) {
|
||||
const wallets = useContractUniqueWalletAnalytics(props);
|
||||
const transactions = useContractTransactionAnalytics(props);
|
||||
const events = useContractEventAnalytics(props);
|
||||
const isPending =
|
||||
wallets.isPending || transactions.isPending || events.isPending;
|
||||
|
||||
@@ -135,7 +104,32 @@ function OverviewAnalytics(props: ChartProps) {
|
||||
data={mergedData || []}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
customHeader={
|
||||
<div className="flex items-center justify-between gap-4 border-b p-6 py-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Analytics</h2>
|
||||
<Button
|
||||
asChild
|
||||
className="gap-2 bg-background text-muted-foreground"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: props.trackingCategory,
|
||||
action: "click",
|
||||
label: "view_all_analytics",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/${props.chainSlug}/${props.contractAddress}/analytics`}
|
||||
>
|
||||
<span>View All</span>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+36
-74
@@ -1,79 +1,41 @@
|
||||
"use client";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ReactIcon } from "../../../../../../../components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "../../../../../../../components/icons/brand-icons/TypeScriptIcon";
|
||||
|
||||
import {
|
||||
Flex,
|
||||
GridItem,
|
||||
LinkBox,
|
||||
LinkOverlay,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChakraNextImage as Image } from "components/Image";
|
||||
import { PRODUCTS } from "components/product-pages/common/nav/data";
|
||||
import { Card } from "tw-components/card";
|
||||
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
|
||||
import { Text } from "tw-components/text";
|
||||
|
||||
const RENDERED_PRODUCTS = ["sdk", "storage", "ui-components", "auth"];
|
||||
|
||||
interface BuildYourAppProps {
|
||||
trackingCategory: TrackedLinkProps["category"];
|
||||
export function BuildYourApp(props: {
|
||||
trackingCategory: string;
|
||||
contractAddress: string;
|
||||
chainSlug: string;
|
||||
}
|
||||
|
||||
export const BuildYourApp: React.FC<BuildYourAppProps> = ({
|
||||
trackingCategory,
|
||||
contractAddress,
|
||||
chainSlug,
|
||||
}) => {
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
px={{ base: 4, md: 8 }}
|
||||
py={{ base: 6, md: 8 }}
|
||||
as={LinkBox}
|
||||
transition="all 0.2s"
|
||||
_hover={{ borderColor: "whiteAlpha.300" }}
|
||||
>
|
||||
<SimpleGrid {...{ columns: { base: 1, md: 2 } }} gap={8}>
|
||||
<GridItem as={Flex} direction="column" gap={4}>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Build your app
|
||||
</h2>
|
||||
<Text size="body.md">
|
||||
<LinkOverlay
|
||||
as={TrackedLink}
|
||||
category={trackingCategory}
|
||||
label="build_your_app"
|
||||
href={`/${chainSlug}/${contractAddress}/code`}
|
||||
color="blue.500"
|
||||
>
|
||||
Learn more
|
||||
</LinkOverlay>{" "}
|
||||
about how you can use thirdweb tools to build apps on top of this
|
||||
contract.
|
||||
</Text>
|
||||
</GridItem>
|
||||
<GridItem as={Flex} align="center" justify="end" gap={3}>
|
||||
{RENDERED_PRODUCTS.map((p) => {
|
||||
const product = PRODUCTS.find((item) => item.label === p);
|
||||
return (
|
||||
product?.icon && (
|
||||
<Flex
|
||||
key={product.name}
|
||||
rounded="full"
|
||||
bg="#0E0E10"
|
||||
align="center"
|
||||
justify="center"
|
||||
w={14}
|
||||
h={14}
|
||||
>
|
||||
<Image boxSize={7} src={product.icon} alt={product.name} />
|
||||
</Flex>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
<div className="relative flex flex-col justify-between gap-4 rounded-lg border bg-card p-6 hover:border-active-border lg:flex-row">
|
||||
{/* left */}
|
||||
<div className="max-w-sm">
|
||||
<h2 className="mb-2 font-semibold text-xl leading-none tracking-tight">
|
||||
Build your app
|
||||
</h2>
|
||||
<TrackedLinkTW
|
||||
category={props.trackingCategory}
|
||||
label="build_your_app"
|
||||
href={`/${props.chainSlug}/${props.contractAddress}/code`}
|
||||
className="block text-balance text-muted-foreground text-sm before:absolute before:inset-0"
|
||||
>
|
||||
Learn more about how you can use thirdweb tools to build apps on top
|
||||
of this contract
|
||||
</TrackedLinkTW>{" "}
|
||||
</div>
|
||||
|
||||
{/* right */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{[TypeScriptIcon, ReactIcon].map((Icon, i) => {
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<div key={i} className="rounded-full border bg-background p-3">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { LatestEventsUI } from "./LatestEvents";
|
||||
|
||||
const meta: Meta<typeof LatestEventsUI> = {
|
||||
title: "Contracts/Overview/LatestEvents",
|
||||
component: LatestEventsUI,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container max-w-4xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
allEvents: [],
|
||||
autoUpdate: false,
|
||||
eventsHref: "/ethereum/0x123456789/events",
|
||||
trackingCategory: "test-category",
|
||||
},
|
||||
};
|
||||
|
||||
function eventStub(eventName: string) {
|
||||
return {
|
||||
logIndex: 0,
|
||||
eventName,
|
||||
address: "0x",
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
|
||||
const txStub =
|
||||
"0x9ef44ea701637e0f7c42666b6f99f4aad9aa6b9b51995cd22df1507288e51427";
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LatestEventsUI>;
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
allEvents: [],
|
||||
autoUpdate: true,
|
||||
eventsHref: "/ethereum/0x123456789/events",
|
||||
},
|
||||
};
|
||||
|
||||
export const NoEvents: Story = {
|
||||
args: {
|
||||
allEvents: [],
|
||||
autoUpdate: false,
|
||||
eventsHref: "/ethereum/0x123456789/events",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithEvents: Story = {
|
||||
args: {
|
||||
allEvents: [
|
||||
{
|
||||
transactionHash: txStub,
|
||||
events: [eventStub("Transfer"), eventStub("Approval")],
|
||||
},
|
||||
{
|
||||
transactionHash: txStub,
|
||||
events: [eventStub("Mint")],
|
||||
},
|
||||
{
|
||||
transactionHash: txStub,
|
||||
events: [
|
||||
eventStub("Transfer"),
|
||||
eventStub("Burn"),
|
||||
eventStub("Approval"),
|
||||
],
|
||||
},
|
||||
],
|
||||
autoUpdate: false,
|
||||
eventsHref: "/ethereum/0x123456789/events",
|
||||
},
|
||||
};
|
||||
|
||||
export const LiveWithEvents: Story = {
|
||||
args: {
|
||||
allEvents: [
|
||||
{
|
||||
transactionHash: txStub,
|
||||
events: [eventStub("Transfer"), eventStub("Approval")],
|
||||
},
|
||||
{
|
||||
transactionHash: txStub,
|
||||
events: [eventStub("Mint")],
|
||||
},
|
||||
{
|
||||
transactionHash: txStub,
|
||||
events: [
|
||||
eventStub("Transfer"),
|
||||
eventStub("Burn"),
|
||||
eventStub("Approval"),
|
||||
],
|
||||
},
|
||||
{
|
||||
transactionHash: txStub,
|
||||
events: [eventStub("RoleGranted"), eventStub("OwnershipTransferred")],
|
||||
},
|
||||
],
|
||||
autoUpdate: true,
|
||||
eventsHref: "/ethereum/0x123456789/events",
|
||||
},
|
||||
};
|
||||
+136
-171
@@ -1,192 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import {
|
||||
type InternalTransaction,
|
||||
useActivity,
|
||||
} from "@3rdweb-sdk/react/hooks/useActivity";
|
||||
import {
|
||||
Box,
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
List,
|
||||
SimpleGrid,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Heading,
|
||||
LinkButton,
|
||||
Text,
|
||||
TrackedLink,
|
||||
type TrackedLinkProps,
|
||||
} from "tw-components";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
|
||||
interface LatestEventsProps {
|
||||
trackingCategory: TrackedLinkProps["category"];
|
||||
export function LatestEvents(props: {
|
||||
trackingCategory: string;
|
||||
contract: ThirdwebContract;
|
||||
chainSlug: string;
|
||||
}
|
||||
|
||||
export const LatestEvents: React.FC<LatestEventsProps> = ({
|
||||
contract,
|
||||
trackingCategory,
|
||||
chainSlug,
|
||||
}) => {
|
||||
const [autoUpdate] = useState(true);
|
||||
const eventsHref = `/${chainSlug}/${contract.address}/events`;
|
||||
const allEvents = useActivity(contract, autoUpdate);
|
||||
}) {
|
||||
const autoUpdate = true;
|
||||
const allEvents = useActivity(props.contract, autoUpdate);
|
||||
|
||||
return (
|
||||
<Flex gap={6} flexDirection="column">
|
||||
<Flex align="center" justify="space-between" w="full">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Latest Events</h2>
|
||||
<TrackedLink
|
||||
category={trackingCategory}
|
||||
label="view_all_events"
|
||||
color="blue.400"
|
||||
_light={{
|
||||
color: "blue.600",
|
||||
}}
|
||||
gap={4}
|
||||
href={eventsHref}
|
||||
>
|
||||
View all ->
|
||||
</TrackedLink>
|
||||
</Flex>
|
||||
<Card p={0} overflow="hidden">
|
||||
<SimpleGrid
|
||||
gap={2}
|
||||
columns={9}
|
||||
borderBottomWidth="1px"
|
||||
borderColor="borderColor"
|
||||
padding={4}
|
||||
bg="blackAlpha.50"
|
||||
_dark={{ bg: "whiteAlpha.50" }}
|
||||
>
|
||||
<Heading gridColumn="span 3" size="label.md">
|
||||
Transaction Hash
|
||||
</Heading>
|
||||
<Heading gridColumn="span 6" size="label.md">
|
||||
Events
|
||||
</Heading>
|
||||
</SimpleGrid>
|
||||
|
||||
<List overflow="auto">
|
||||
{allEvents.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Flex align="center" gap={2}>
|
||||
{autoUpdate && <Spinner size="sm" speed="0.69s" />}
|
||||
<Text size="body.md" fontStyle="italic">
|
||||
{autoUpdate ? "listening for events" : "no events to show"}
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{allEvents?.slice(0, 3).map((e) => (
|
||||
<EventsFeedItem
|
||||
key={e.transactionHash}
|
||||
transaction={e}
|
||||
contractAddress={contract.address}
|
||||
chainSlug={chainSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
</Card>
|
||||
</Flex>
|
||||
<LatestEventsUI
|
||||
allEvents={allEvents}
|
||||
autoUpdate={autoUpdate}
|
||||
eventsHref={`/${props.chainSlug}/${props.contract.address}/events`}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface EventsFeedItemProps {
|
||||
transaction: InternalTransaction;
|
||||
contractAddress: string;
|
||||
chainSlug: string;
|
||||
}
|
||||
|
||||
const EventsFeedItem: React.FC<EventsFeedItemProps> = ({
|
||||
transaction,
|
||||
contractAddress,
|
||||
chainSlug,
|
||||
}) => {
|
||||
const { onCopy } = useClipboard(transaction.transactionHash);
|
||||
const eventsHref = `/${chainSlug}/${contractAddress}/events`;
|
||||
|
||||
export function LatestEventsUI(props: {
|
||||
allEvents: Pick<InternalTransaction, "transactionHash" | "events">[];
|
||||
autoUpdate: boolean;
|
||||
eventsHref: string;
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
const { allEvents, autoUpdate, eventsHref } = props;
|
||||
return (
|
||||
<div>
|
||||
<SimpleGrid
|
||||
columns={9}
|
||||
gap={2}
|
||||
as="li"
|
||||
borderBottomWidth="1px"
|
||||
borderColor="borderColor"
|
||||
padding={4}
|
||||
overflow="hidden"
|
||||
alignItems="center"
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
>
|
||||
<Box gridColumn="span 3">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Tooltip
|
||||
p={0}
|
||||
bg="transparent"
|
||||
boxShadow="none"
|
||||
label={
|
||||
<Card py={2} px={4} bgColor="backgroundHighlight">
|
||||
<Text size="label.sm">
|
||||
Copy transaction hash to clipboard
|
||||
</Text>
|
||||
</Card>
|
||||
}
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
<div className="flex w-full items-center justify-between border-b px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Latest Events
|
||||
</h2>
|
||||
{autoUpdate && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="hidden gap-2 bg-background text-sm lg:flex"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
bg="transparent"
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
toast.info("Transaction hash copied.");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Text fontFamily="mono" noOfLines={1}>
|
||||
{shortenString(transaction.transactionHash)}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<ButtonGroup
|
||||
size="sm"
|
||||
variant="outline"
|
||||
gridColumn="span 6"
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
spacing={0}
|
||||
>
|
||||
{transaction.events.slice(0, 2).map((e, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<LinkButton key={idx} href={`${eventsHref}?event=${e.eventName}`}>
|
||||
<Text color="whiteBg" fontWeight="600" isTruncated>
|
||||
{e.eventName}
|
||||
</Text>
|
||||
</LinkButton>
|
||||
))}
|
||||
{transaction.events.length > 2 && (
|
||||
<Button as="span" pointerEvents="none">
|
||||
+ {transaction.events.length - 2}
|
||||
</Button>
|
||||
<span className="!pointer-events-auto relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="bg-background">
|
||||
<TrackedLinkTW
|
||||
category={props.trackingCategory}
|
||||
label="view_all_events"
|
||||
href={eventsHref}
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm"
|
||||
>
|
||||
View all <ArrowRightIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* table */}
|
||||
<TableContainer className="w-full border-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-52">Transaction Hash</TableHead>
|
||||
<TableHead>Events</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{allEvents.slice(0, 3).map((transaction) => {
|
||||
return (
|
||||
<TableRow key={transaction.transactionHash}>
|
||||
<TableCell>
|
||||
<CopyTextButton
|
||||
textToShow={shortenString(transaction.transactionHash)}
|
||||
textToCopy={transaction.transactionHash}
|
||||
tooltip="Copy transaction hash"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
className="-translate-x-2 font-mono"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex w-max flex-wrap gap-2">
|
||||
{transaction.events.slice(0, 3).map((e) => (
|
||||
<Button
|
||||
key={e.logIndex + e.address + e.eventName}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto rounded-full py-1"
|
||||
asChild
|
||||
>
|
||||
<Link href={`${eventsHref}?event=${e.eventName}`}>
|
||||
{e.eventName}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{transaction.events.length > 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto rounded-full py-1 hover:bg-transparent"
|
||||
asChild
|
||||
>
|
||||
<div>+ {transaction.events.length - 3}</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{allEvents.length === 0 && (
|
||||
<div className="flex h-48 items-center justify-center text-sm">
|
||||
{autoUpdate ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
<p>Listening for events</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>No events found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+75
-45
@@ -2,10 +2,12 @@
|
||||
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
type DirectListing,
|
||||
type EnglishAuction,
|
||||
@@ -79,28 +81,37 @@ const DirectListingCards: React.FC<ListingCardsSectionProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Direct Listing
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex w-full items-center justify-between border-b px-6 py-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Direct Listings
|
||||
</h2>
|
||||
<TrackedLinkTW
|
||||
category={trackingCategory}
|
||||
label="view_all_direct_listings"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href={directListingsHref}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-background text-muted-foreground"
|
||||
>
|
||||
View all ->
|
||||
</TrackedLinkTW>
|
||||
<TrackedLinkTW
|
||||
category={trackingCategory}
|
||||
label="view_all_direct_listings"
|
||||
href={directListingsHref}
|
||||
>
|
||||
View all <ArrowRightIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
<ListingCards
|
||||
listings={listings}
|
||||
isPending={listingsQuery.isPending}
|
||||
trackingCategory={trackingCategory}
|
||||
chainSlug={chainSlug}
|
||||
contractAddress={contract.address}
|
||||
/>
|
||||
</>
|
||||
|
||||
<div className="p-6">
|
||||
<ListingCards
|
||||
listings={listings}
|
||||
isPending={listingsQuery.isPending}
|
||||
trackingCategory={trackingCategory}
|
||||
chainSlug={chainSlug}
|
||||
contractAddress={contract.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -142,28 +153,36 @@ const EnglishAuctionCards: React.FC<ListingCardsSectionProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex w-full items-center justify-between border-b px-6 py-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
English Auctions
|
||||
</h2>
|
||||
<TrackedLinkTW
|
||||
category={trackingCategory}
|
||||
label="view_all_english_auctions"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href={englishAuctionsHref}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-background text-muted-foreground"
|
||||
>
|
||||
View all ->
|
||||
</TrackedLinkTW>
|
||||
<TrackedLinkTW
|
||||
category={trackingCategory}
|
||||
label="view_all_english_auctions"
|
||||
href={englishAuctionsHref}
|
||||
>
|
||||
View all <ArrowRightIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
<ListingCards
|
||||
listings={auctions}
|
||||
isPending={auctionsQuery.isPending}
|
||||
trackingCategory={trackingCategory}
|
||||
chainSlug={chainSlug}
|
||||
contractAddress={contract.address}
|
||||
/>
|
||||
</>
|
||||
<div className="p-6">
|
||||
<ListingCards
|
||||
listings={auctions}
|
||||
isPending={auctionsQuery.isPending}
|
||||
trackingCategory={trackingCategory}
|
||||
chainSlug={chainSlug}
|
||||
contractAddress={contract.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -183,8 +202,7 @@ export const MarketplaceDetails: React.FC<MarketplaceDetailsVersionProps> = ({
|
||||
chainSlug,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Listings</h2>
|
||||
<div className="flex flex-col gap-10">
|
||||
<ListingStatsV3
|
||||
contract={contract}
|
||||
hasDirectListings={hasDirectListings}
|
||||
@@ -219,6 +237,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({
|
||||
supply: BigInt(1),
|
||||
tokenURI: "",
|
||||
type: "ERC721",
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
chainId: 1,
|
||||
},
|
||||
currencyValuePerToken: {
|
||||
decimals: 18,
|
||||
@@ -226,6 +246,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({
|
||||
name: "Ether",
|
||||
symbol: "ETH",
|
||||
value: 0n,
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
chainId: 1,
|
||||
},
|
||||
creatorAddress: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
type: "direct-listing",
|
||||
@@ -235,6 +257,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({
|
||||
value: 0n,
|
||||
displayValue: "0.0",
|
||||
decimals: 18,
|
||||
tokenAddress: ZERO_ADDRESS,
|
||||
chainId: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -262,12 +286,16 @@ const ListingCards: React.FC<ListingCardsProps> = ({
|
||||
const englishAuctionsHref = `/${chainSlug}/${contractAddress}/english-auctions`;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 md:gap-6 max-sm:[&>*:nth-child(n+3)]:hidden">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 ">
|
||||
{listings.map((listing, index) => (
|
||||
<div
|
||||
className="relative rounded-lg border border-border bg-card transition-colors hover:border-active-border"
|
||||
className="group hover:-translate-y-0.5 relative flex h-full cursor-pointer flex-col rounded-lg bg-background duration-200 hover:scale-[1.01]"
|
||||
key={`${listing.creatorAddress}-${index}`}
|
||||
>
|
||||
{/* border */}
|
||||
<div className="absolute inset-0 rounded-lg border border-border" />
|
||||
|
||||
{/* image */}
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
|
||||
{/* Image */}
|
||||
<SkeletonContainer
|
||||
@@ -324,21 +352,23 @@ const ListingCards: React.FC<ListingCardsProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* seller */}
|
||||
<SkeletonContainer
|
||||
loadedData={isPending ? undefined : listing.creatorAddress}
|
||||
skeletonData={listing.creatorAddress}
|
||||
className="mt-4"
|
||||
className="mt-4 border-t pt-4"
|
||||
render={(v) => (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Seller</p>
|
||||
<p className="text-muted-foreground text-xs">Seller</p>
|
||||
<WalletAddress
|
||||
className="relative z-[1] self-start"
|
||||
className="relative z-[1] self-start text-xs"
|
||||
address={v}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* price */}
|
||||
{!isPending && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
+39
-36
@@ -1,27 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Flex, useBreakpointValue } from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import * as ERC721 from "thirdweb/extensions/erc721";
|
||||
import * as ERC1155 from "thirdweb/extensions/erc1155";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
|
||||
import { NFTCards } from "../../_components/NFTCards";
|
||||
|
||||
interface NFTDetailsProps {
|
||||
type NFTDetailsProps = {
|
||||
contract: ThirdwebContract;
|
||||
trackingCategory: TrackedLinkProps["category"];
|
||||
trackingCategory: string;
|
||||
isErc721: boolean;
|
||||
chainSlug: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const NFTDetails: React.FC<NFTDetailsProps> = ({
|
||||
export function NFTDetails({
|
||||
contract,
|
||||
trackingCategory,
|
||||
isErc721,
|
||||
chainSlug,
|
||||
}) => {
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
}: NFTDetailsProps) {
|
||||
const nftsHref = `/${chainSlug}/${contract.address}/nfts`;
|
||||
|
||||
const nftQuery = useReadContract(
|
||||
@@ -36,34 +36,37 @@ export const NFTDetails: React.FC<NFTDetailsProps> = ({
|
||||
const displayableNFTs =
|
||||
nftQuery.data
|
||||
?.filter((token) => token.metadata.image || token.metadata.animation_url)
|
||||
.slice(0, isMobile ? 2 : 3) || [];
|
||||
.slice(0, 3) || [];
|
||||
|
||||
return displayableNFTs.length === 0 && !nftQuery.isPending ? null : (
|
||||
<Flex direction="column" gap={{ base: 3, md: 6 }}>
|
||||
<Flex align="center" justify="space-between" w="full">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">NFT Details</h2>
|
||||
<TrackedLink
|
||||
category={trackingCategory}
|
||||
label="view_all_nfts"
|
||||
color="blue.400"
|
||||
_light={{
|
||||
color: "blue.600",
|
||||
}}
|
||||
gap={4}
|
||||
href={nftsHref}
|
||||
>
|
||||
View all ->
|
||||
</TrackedLink>
|
||||
</Flex>
|
||||
<NFTCards
|
||||
nfts={displayableNFTs.map((t) => ({
|
||||
...t,
|
||||
contractAddress: contract.address,
|
||||
chainId: contract.chain.id,
|
||||
}))}
|
||||
trackingCategory={trackingCategory}
|
||||
isPending={nftQuery.isPending}
|
||||
/>
|
||||
</Flex>
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">NFTs</h2>
|
||||
<Button variant="outline" asChild className="bg-background" size="sm">
|
||||
<TrackedLinkTW
|
||||
category={trackingCategory}
|
||||
label="view_all_nfts"
|
||||
href={nftsHref}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
View all <ArrowRightIcon className="ml-2 size-4 " />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* cards */}
|
||||
<div className="p-6">
|
||||
<NFTCards
|
||||
nfts={displayableNFTs.map((t) => ({
|
||||
...t,
|
||||
contractAddress: contract.address,
|
||||
chainId: contract.chain.id,
|
||||
}))}
|
||||
trackingCategory={trackingCategory}
|
||||
isPending={nftQuery.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { PermissionsTableUI } from "./PermissionsTable";
|
||||
|
||||
const meta: Meta<typeof PermissionsTableUI> = {
|
||||
title: "Contracts/Overview/PermissionsTable",
|
||||
component: PermissionsTableUI,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThirdwebProvider>
|
||||
<div className="container max-w-4xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
viewMoreLink: "#",
|
||||
trackingCategory: "test-category",
|
||||
members: [],
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PermissionsTableUI>;
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isPending: true,
|
||||
members: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoMembers: Story = {
|
||||
args: {
|
||||
isPending: false,
|
||||
members: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const OneMember: Story = {
|
||||
args: {
|
||||
isPending: false,
|
||||
members: [
|
||||
{
|
||||
member: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
roles: ["admin"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FiveMembers: Story = {
|
||||
args: {
|
||||
isPending: false,
|
||||
members: [
|
||||
{
|
||||
member: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
roles: ["admin", "minter"],
|
||||
},
|
||||
{
|
||||
member: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
|
||||
roles: ["transfer"],
|
||||
},
|
||||
{
|
||||
member: "0x1f4202eD5c33d229bCda7B9B6AB3B57caDf423e6",
|
||||
roles: ["admin", "transfer", "minter"],
|
||||
},
|
||||
{
|
||||
member: "0x6844F0056d4C84CA2a894Fcf85473d75d8252b24",
|
||||
roles: [
|
||||
"admin",
|
||||
"minter",
|
||||
"transfer",
|
||||
"metadata",
|
||||
"burner",
|
||||
"manager",
|
||||
"lister",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
+90
-138
@@ -1,36 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { Box, Flex, List, SimpleGrid, Tag } from "@chakra-ui/react";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { getAllRoleMembers } from "contract-ui/hooks/permissions";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { Button } from "tw-components/button";
|
||||
import { Card } from "tw-components/card";
|
||||
import { Heading } from "tw-components/heading";
|
||||
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
|
||||
import { Text } from "tw-components/text";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
|
||||
interface PermissionsTableProps {
|
||||
export function PermissionsTable(props: {
|
||||
contract: ThirdwebContract;
|
||||
trackingCategory: TrackedLinkProps["category"];
|
||||
trackingCategory: string;
|
||||
chainSlug: string;
|
||||
}
|
||||
|
||||
export const PermissionsTable: React.FC<PermissionsTableProps> = ({
|
||||
contract,
|
||||
trackingCategory,
|
||||
chainSlug,
|
||||
}) => {
|
||||
}) {
|
||||
const allRoleMembers = useReadContract(getAllRoleMembers, {
|
||||
contract,
|
||||
contract: props.contract,
|
||||
});
|
||||
const permissionsHref = `/${chainSlug}/${contract.address}/permissions`;
|
||||
|
||||
const members = useMemo(() => {
|
||||
return (
|
||||
Object.entries(allRoleMembers.data || {}).reduce(
|
||||
@@ -52,128 +50,82 @@ export const PermissionsTable: React.FC<PermissionsTableProps> = ({
|
||||
}, [allRoleMembers.data]);
|
||||
|
||||
return (
|
||||
<Flex gap={6} flexDirection="column">
|
||||
<Flex align="center" justify="space-between" w="full">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Permissions</h2>
|
||||
<TrackedLink
|
||||
category={trackingCategory}
|
||||
label="view_all_permissions"
|
||||
color="blue.400"
|
||||
_light={{
|
||||
color: "blue.600",
|
||||
}}
|
||||
gap={4}
|
||||
href={permissionsHref}
|
||||
>
|
||||
View all ->
|
||||
</TrackedLink>
|
||||
</Flex>
|
||||
{contract && (
|
||||
<Card p={0} overflow="hidden">
|
||||
<SimpleGrid
|
||||
gap={2}
|
||||
columns={9}
|
||||
borderBottomWidth="1px"
|
||||
borderColor="borderColor"
|
||||
padding={4}
|
||||
bg="blackAlpha.50"
|
||||
_dark={{ bg: "whiteAlpha.50" }}
|
||||
>
|
||||
<Heading gridColumn="span 3" size="label.md">
|
||||
Member
|
||||
</Heading>
|
||||
<Heading gridColumn="span 6" size="label.md">
|
||||
Roles
|
||||
</Heading>
|
||||
</SimpleGrid>
|
||||
|
||||
<List overflow="auto">
|
||||
{members.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Flex align="center" gap={2}>
|
||||
<Text size="body.md" fontStyle="italic">
|
||||
{allRoleMembers.isPending
|
||||
? "loading permissions"
|
||||
: "no permissions found"}
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{members.map((e) => (
|
||||
<PermissionsItem key={e.member} data={e} />
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
<PermissionsTableUI
|
||||
members={members}
|
||||
isPending={allRoleMembers.isPending}
|
||||
viewMoreLink={`/${props.chainSlug}/${props.contract.address}/permissions`}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface PermissionsItemProps {
|
||||
data: { member: string; roles: string[] };
|
||||
}
|
||||
|
||||
const PermissionsItem: React.FC<PermissionsItemProps> = ({ data }) => {
|
||||
const { onCopy } = useClipboard(data.member);
|
||||
|
||||
export function PermissionsTableUI(props: {
|
||||
viewMoreLink: string;
|
||||
trackingCategory: string;
|
||||
members: { member: string; roles: string[] }[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SimpleGrid
|
||||
columns={9}
|
||||
gap={2}
|
||||
as="li"
|
||||
borderBottomWidth="1px"
|
||||
borderColor="borderColor"
|
||||
padding={4}
|
||||
overflow="hidden"
|
||||
alignItems="center"
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
>
|
||||
<Box gridColumn="span 2">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ToolTipLabel label="Copy address to clipboard">
|
||||
<Button
|
||||
size="sm"
|
||||
bg="transparent"
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
toast.info("Address copied.");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<Text fontFamily="mono" noOfLines={1}>
|
||||
{shortenIfAddress(data.member)}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
<div className="flex w-full items-center justify-between border-b px-6 py-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Permissions</h2>
|
||||
<Button asChild variant="outline" size="sm" className="bg-background">
|
||||
<TrackedLinkTW
|
||||
category={props.trackingCategory}
|
||||
label="view_all_permissions"
|
||||
href={props.viewMoreLink}
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm"
|
||||
>
|
||||
View all <ArrowRightIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Box gridColumn="span 1" />
|
||||
{/* table */}
|
||||
<TableContainer className="w-full border-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Roles</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<Flex gridColumn="span 6" flexWrap="wrap" gap={2}>
|
||||
{data.roles.slice(0, 3).map((role) => (
|
||||
<Tag key={role}>
|
||||
<Text size="body.md" fontWeight="medium">
|
||||
{role}
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
{data.roles.length > 3 && (
|
||||
<Tag
|
||||
border="2px solid"
|
||||
borderColor="var(--badge-bg)"
|
||||
bg="transparent"
|
||||
>
|
||||
<Text size="body.md" fontWeight="medium">
|
||||
+ {data.roles.length - 3}
|
||||
</Text>
|
||||
</Tag>
|
||||
<TableBody>
|
||||
{props.members.map((data) => (
|
||||
<TableRow key={data.member}>
|
||||
<TableCell>
|
||||
<WalletAddress address={data.member} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex w-max flex-wrap gap-2">
|
||||
{data.roles.map((role) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
key={role}
|
||||
className="bg-background py-1 font-normal text-sm capitalize"
|
||||
>
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{props.members.length === 0 && (
|
||||
<div className="flex h-48 items-center justify-center text-sm">
|
||||
{props.isPending ? (
|
||||
<Spinner className="size-8" />
|
||||
) : (
|
||||
<p>No Permissions</p>
|
||||
)}
|
||||
</Flex>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { TokenSupply } from "../../tokens/components/supply";
|
||||
|
||||
interface TokenDetailsProps {
|
||||
contract: ThirdwebContract;
|
||||
}
|
||||
|
||||
export const TokenDetails: React.FC<TokenDetailsProps> = ({ contract }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Token Details</h2>
|
||||
</div>
|
||||
<TokenSupply contract={contract} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+15
-10
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { totalAuctions, totalListings } from "thirdweb/extensions/marketplace";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { StatCard } from "./stat-card";
|
||||
import { Stat } from "./stat-card";
|
||||
|
||||
const TotalListingsStat: React.FC<{ contract: ThirdwebContract }> = ({
|
||||
contract,
|
||||
@@ -19,7 +19,7 @@ const TotalListingsStat: React.FC<{ contract: ThirdwebContract }> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
<Stat
|
||||
value={combinedListingCount.toString()}
|
||||
label="Total Listings"
|
||||
isPending={
|
||||
@@ -37,7 +37,7 @@ const DirectListingsStat: React.FC<{ contract: ThirdwebContract }> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
<Stat
|
||||
value={(directListingsQuery.data || 0n).toString()}
|
||||
isPending={directListingsQuery.isPending}
|
||||
label="Direct Listings"
|
||||
@@ -53,7 +53,7 @@ const EnglishAuctionsStat: React.FC<{ contract: ThirdwebContract }> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
<Stat
|
||||
value={(englishAuctionsQuery.data || 0n).toString()}
|
||||
isPending={englishAuctionsQuery.isPending}
|
||||
label="English Auctions"
|
||||
@@ -73,12 +73,17 @@ export const ListingStatsV3: React.FC<ListingStatsV3Props> = ({
|
||||
hasEnglishAuctions,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-3 md:gap-6 [&>*]:grow">
|
||||
{hasDirectListings && hasEnglishAuctions && contract && (
|
||||
<TotalListingsStat contract={contract} />
|
||||
)}
|
||||
{hasDirectListings && <DirectListingsStat contract={contract} />}
|
||||
{hasEnglishAuctions && <EnglishAuctionsStat contract={contract} />}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<h2 className="border-b px-6 py-5 font-semibold text-xl tracking-tight">
|
||||
Listing Details
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4 p-6 lg:flex-row [&>*]:grow">
|
||||
{hasDirectListings && hasEnglishAuctions && contract && (
|
||||
<TotalListingsStat contract={contract} />
|
||||
)}
|
||||
{hasDirectListings && <DirectListingsStat contract={contract} />}
|
||||
{hasEnglishAuctions && <EnglishAuctionsStat contract={contract} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+27
-10
@@ -1,17 +1,34 @@
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
|
||||
export function StatCard(props: {
|
||||
value: string;
|
||||
isPending: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
isPending,
|
||||
}: { label: string; value: string; isPending: boolean }) {
|
||||
return (
|
||||
<dl className="block rounded-lg border border-border bg-card p-4">
|
||||
<dt className="mb-1.5 text-sm md:text-base">{props.label}</dt>
|
||||
<dl className="grow">
|
||||
<dt className="text-muted-foreground text-sm">{label}</dt>
|
||||
<SkeletonContainer
|
||||
loadedData={props.isPending ? undefined : props.value}
|
||||
skeletonData={"0000"}
|
||||
render={(v) => <dd className="truncate font-semibold text-xl">{v}</dd>}
|
||||
skeletonData={"000000"}
|
||||
loadedData={isPending ? undefined : value}
|
||||
render={(v) => <dd className="font-medium text-lg">{v}</dd>}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
isPending,
|
||||
}: { label: string; value: string; isPending: boolean }) {
|
||||
return (
|
||||
<dl className="grow rounded-lg border bg-card p-4">
|
||||
<dt className="text-muted-foreground text-sm">{label}</dt>
|
||||
<SkeletonContainer
|
||||
skeletonData={"000000"}
|
||||
loadedData={isPending ? undefined : value}
|
||||
render={(v) => <dd className="font-medium text-lg">{v}</dd>}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
|
||||
+32
-47
@@ -1,7 +1,7 @@
|
||||
import { Flex, SimpleGrid, Spinner } from "@chakra-ui/react";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
|
||||
import type { ExtensionDetectedState } from "components/buttons/ExtensionDetectedState";
|
||||
import { ExternalLinkIcon, Grid2x2XIcon } from "lucide-react";
|
||||
import { Heading, Text, TrackedLink } from "tw-components";
|
||||
import { Grid2x2XIcon } from "lucide-react";
|
||||
|
||||
const settingTypeMap = {
|
||||
metadata: {
|
||||
@@ -26,15 +26,13 @@ const settingTypeMap = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface SettingDetectedStateProps {
|
||||
type: keyof typeof settingTypeMap;
|
||||
detectedState: ExtensionDetectedState;
|
||||
}
|
||||
|
||||
export const SettingDetectedState: React.FC<SettingDetectedStateProps> = ({
|
||||
export function SettingDetectedState({
|
||||
type,
|
||||
detectedState,
|
||||
}) => {
|
||||
}: {
|
||||
type: keyof typeof settingTypeMap;
|
||||
detectedState: ExtensionDetectedState;
|
||||
}) {
|
||||
if (detectedState === "enabled") {
|
||||
return null;
|
||||
}
|
||||
@@ -42,45 +40,32 @@ export const SettingDetectedState: React.FC<SettingDetectedStateProps> = ({
|
||||
const metadata = settingTypeMap[type];
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
bottom={0}
|
||||
right={0}
|
||||
zIndex={1}
|
||||
placeItems="center"
|
||||
px={{ base: 6, md: 0 }}
|
||||
borderRadius="md"
|
||||
className="bg-background"
|
||||
>
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-card p-6">
|
||||
{detectedState === "loading" ? (
|
||||
<Spinner size="sm" />
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Flex flexDir="column" gap={3}>
|
||||
<Flex align="center" gap={2}>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full border bg-background">
|
||||
<Grid2x2XIcon className="size-4 text-red-500" />
|
||||
<Heading size="subtitle.md">Missing extension</Heading>
|
||||
</Flex>
|
||||
<Text>
|
||||
This contract does not implement the required extension for{" "}
|
||||
<strong>{metadata.name}</strong>.
|
||||
</Text>
|
||||
<TrackedLink
|
||||
category="contract-settings"
|
||||
label="metadata-extension"
|
||||
href={metadata.portalLink}
|
||||
isExternal
|
||||
display="flex"
|
||||
flexDir="row"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
>
|
||||
Learn how to enable this extension
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</TrackedLink>
|
||||
</Flex>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-lg">Missing extension</h3>
|
||||
<p className="mb-0.5 text-muted-foreground text-sm">
|
||||
This contract does not implement the required extension for{" "}
|
||||
<span className="text-foreground">{metadata.name}</span>
|
||||
</p>
|
||||
<TrackedUnderlineLink
|
||||
category="contract-settings"
|
||||
label="metadata-extension"
|
||||
href={metadata.portalLink}
|
||||
target="_blank"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
Learn how to enable this extension
|
||||
</TrackedUnderlineLink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ export const SettingsMetadata = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p={0} position="relative">
|
||||
<Card p={0} position="relative" overflow="hidden">
|
||||
<SettingDetectedState type="metadata" detectedState={detectedState} />
|
||||
<Flex
|
||||
as="form"
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const SettingsPlatformFees = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p={0} position="relative">
|
||||
<Card p={0} position="relative" overflow="hidden">
|
||||
<SettingDetectedState type="platformFee" detectedState={detectedState} />
|
||||
<Flex
|
||||
as="form"
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export const SettingsPrimarySale = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p={0} position="relative">
|
||||
<Card p={0} position="relative" overflow="hidden">
|
||||
<SettingDetectedState type="primarySale" detectedState={detectedState} />
|
||||
<Flex
|
||||
as="form"
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ export const SettingsRoyalties = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p={0} position="relative">
|
||||
<Card p={0} position="relative" overflow="hidden">
|
||||
<SettingDetectedState type="royalties" detectedState={detectedState} />
|
||||
<Flex
|
||||
as="form"
|
||||
|
||||
+126
-152
@@ -1,41 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useResolveContractAbi } from "@3rdweb-sdk/react/hooks/useResolveContractAbi";
|
||||
import {
|
||||
Divider,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Spinner,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { SourcesPanel } from "components/contract-components/shared/sources-panel";
|
||||
import { useContractSources } from "contract-ui/hooks/useContractSources";
|
||||
import { CircleCheckIcon, CircleXIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
CircleXIcon,
|
||||
RefreshCcwIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { Heading } from "tw-components";
|
||||
|
||||
interface ContractSourcesPageProps {
|
||||
contract: ThirdwebContract;
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
type VerificationResult = {
|
||||
explorerUrl: string;
|
||||
success: boolean;
|
||||
alreadyVerified: boolean;
|
||||
error?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export async function verifyContract(contract: ThirdwebContract) {
|
||||
try {
|
||||
@@ -63,111 +58,80 @@ export async function verifyContract(contract: ThirdwebContract) {
|
||||
}
|
||||
}
|
||||
|
||||
interface ConnectorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
function VerifyContractModalContent({
|
||||
contract,
|
||||
}: {
|
||||
contract: ThirdwebContract;
|
||||
}
|
||||
|
||||
const VerifyContractModal: React.FC<
|
||||
ConnectorModalProps & { resetSignal: number }
|
||||
> = ({ isOpen, onClose, contract, resetSignal }) => {
|
||||
}) {
|
||||
const verifyQuery = useQuery({
|
||||
queryKey: [
|
||||
"verify-contract",
|
||||
contract.chain.id,
|
||||
contract.address,
|
||||
resetSignal,
|
||||
],
|
||||
queryKey: ["verify-contract", contract.chain.id, contract.address],
|
||||
queryFn: () => verifyContract(contract),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
className="!bg-background rounded-lg border border-border"
|
||||
pb={2}
|
||||
mx={{ base: 4, md: 0 }}
|
||||
>
|
||||
<ModalHeader>
|
||||
<Flex gap={2} align="center">
|
||||
<Heading size="subtitle.md">Contract Verification</Heading>
|
||||
<Badge>beta</Badge>
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton mt={2} />
|
||||
<Divider mb={4} />
|
||||
<ModalBody py={4}>
|
||||
<Flex flexDir="column">
|
||||
{verifyQuery.isPending && (
|
||||
<Flex gap={2} align="center">
|
||||
<Spinner color="purple.500" size="sm" />
|
||||
<Heading size="label.md">Verifying...</Heading>
|
||||
</Flex>
|
||||
)}
|
||||
{verifyQuery?.error ? (
|
||||
<Flex gap={2} align="center">
|
||||
<CircleXIcon className="size-4 text-red-600" />
|
||||
<Heading size="label.md">
|
||||
{verifyQuery?.error.toString()}
|
||||
</Heading>
|
||||
</Flex>
|
||||
) : null}
|
||||
<div className="flex flex-col p-6">
|
||||
{verifyQuery.isPending && (
|
||||
<div className="flex min-h-24 items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
<p className="font-medium text-sm">Verifying</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verifyQuery.data?.results
|
||||
? verifyQuery.data?.results.map(
|
||||
(result: VerificationResult, index: number) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Flex key={index} gap={2} align="center" mb={4}>
|
||||
{result.success && (
|
||||
<>
|
||||
<CircleCheckIcon className="size-4 text-green-600" />
|
||||
{result.alreadyVerified && (
|
||||
<Heading size="label.md">
|
||||
{result.explorerUrl}: Already verified
|
||||
</Heading>
|
||||
)}
|
||||
{!result.alreadyVerified && (
|
||||
<Heading size="label.md">
|
||||
{result.explorerUrl}: Verification successful
|
||||
</Heading>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!result.success && (
|
||||
<>
|
||||
<CircleXIcon className="size-4 text-red-600" />
|
||||
<Heading size="label.md">
|
||||
{`${result.explorerUrl}: Verification failed`}
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
)
|
||||
: null}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{verifyQuery?.error ? (
|
||||
<div className="flex min-h-24 items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleXIcon className="size-4 text-red-600" />
|
||||
<p className="font-medium text-sm">
|
||||
{verifyQuery?.error.toString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{verifyQuery.data?.results ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{verifyQuery.data.results.map(
|
||||
(result: VerificationResult, index: number) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{result.success && (
|
||||
<>
|
||||
<CircleCheckIcon className="size-4 text-green-600" />
|
||||
{result.alreadyVerified && (
|
||||
<p className="font-medium text-sm">
|
||||
{result.explorerUrl}: Already verified
|
||||
</p>
|
||||
)}
|
||||
{!result.alreadyVerified && (
|
||||
<p className="font-medium text-sm">
|
||||
{result.explorerUrl}: Verification successful
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!result.success && (
|
||||
<>
|
||||
<CircleXIcon className="size-4 text-red-600" />
|
||||
<p className="font-medium text-sm">
|
||||
{`${result.explorerUrl}: Verification failed`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const ContractSourcesPage: React.FC<ContractSourcesPageProps> = ({
|
||||
export function ContractSourcesPage({
|
||||
contract,
|
||||
}) => {
|
||||
const [resetSignal, setResetSignal] = useState(0);
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
// Increment to reset the query in the child component
|
||||
setResetSignal((prev: number) => prev + 1);
|
||||
};
|
||||
|
||||
}: { contract: ThirdwebContract }) {
|
||||
const contractSourcesQuery = useContractSources(contract);
|
||||
const abiQuery = useResolveContractAbi(contract);
|
||||
|
||||
@@ -187,44 +151,49 @@ export const ContractSourcesPage: React.FC<ContractSourcesPageProps> = ({
|
||||
.reverse();
|
||||
}, [contractSourcesQuery.data]);
|
||||
|
||||
if (!contractSourcesQuery || contractSourcesQuery?.isPending) {
|
||||
return (
|
||||
<Flex direction="row" align="center" gap={2}>
|
||||
<Spinner color="purple.500" size="xs" />
|
||||
<Heading size="title.sm">Loading...</Heading>
|
||||
</Flex>
|
||||
);
|
||||
if (!contractSourcesQuery || contractSourcesQuery.isPending) {
|
||||
return <GenericLoadingPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VerifyContractModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => handleClose()}
|
||||
contract={contract}
|
||||
resetSignal={resetSignal}
|
||||
/>
|
||||
|
||||
<Flex direction="column" gap={8}>
|
||||
<Flex direction="row" alignItems="center" gap={2}>
|
||||
<Heading size="title.sm" flex={1}>
|
||||
Sources
|
||||
</Heading>
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Sources</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View ABI and source code for the contract
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshContractMetadataButton
|
||||
chainId={contract.chain.id}
|
||||
contractAddress={contract.address}
|
||||
/>
|
||||
<Button variant="primary" onClick={onOpen}>
|
||||
Verify contract
|
||||
</Button>
|
||||
</Flex>
|
||||
<Card>
|
||||
<SourcesPanel sources={sources} abi={abiQuery.data} />
|
||||
</Card>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" size="sm">
|
||||
<ShieldCheckIcon className="size-4" />
|
||||
Verify contract
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b p-6">
|
||||
<DialogTitle>Verify Contract</DialogTitle>
|
||||
</DialogHeader>
|
||||
<VerifyContractModalContent contract={contract} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<div className="rounded-lg border bg-card">
|
||||
<SourcesPanel sources={sources} abi={abiQuery.data} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function RefreshContractMetadataButton(props: {
|
||||
chainId: number;
|
||||
@@ -270,14 +239,19 @@ function RefreshContractMetadataButton(props: {
|
||||
onClick={() => {
|
||||
toast.promise(contractCacheMutation.mutateAsync(), {
|
||||
duration: 5000,
|
||||
loading: "Refreshing contract data...",
|
||||
success: () => "Contract data refreshed!",
|
||||
success: () => "Contract refreshed successfully",
|
||||
error: (e) => e?.message || "Failed to refresh contract data.",
|
||||
});
|
||||
}}
|
||||
className="w-[182px]"
|
||||
size="sm"
|
||||
className="gap-2 bg-card"
|
||||
>
|
||||
{contractCacheMutation.isPending ? <Spinner /> : "Refresh Contract Data"}
|
||||
{contractCacheMutation.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
)}
|
||||
Refresh Contract <span className="max-sm:hidden"> Data </span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import { TokenAirdropButton } from "./components/airdrop-button";
|
||||
import { TokenBurnButton } from "./components/burn-button";
|
||||
import { TokenClaimButton } from "./components/claim-button";
|
||||
import { TokenMintButton } from "./components/mint-button";
|
||||
import { TokenSupply } from "./components/supply";
|
||||
import { TokenDetailsCard } from "./components/supply";
|
||||
import { TokenTransferButton } from "./components/transfer-button";
|
||||
|
||||
interface ContractTokenPageProps {
|
||||
@@ -49,7 +49,7 @@ export const ContractTokensPage: React.FC<ContractTokenPageProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<Heading size="title.sm">Contract Tokens</Heading>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Tokens</h2>
|
||||
<div className="flex flex-col gap-3 md:flex-row">
|
||||
{isClaimToSupported && (
|
||||
<TokenClaimButton contract={contract} twAccount={twAccount} />
|
||||
@@ -63,7 +63,7 @@ export const ContractTokensPage: React.FC<ContractTokenPageProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokenSupply contract={contract} />
|
||||
<TokenDetailsCard contract={contract} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+39
-53
@@ -1,60 +1,46 @@
|
||||
import {
|
||||
Skeleton,
|
||||
Stat,
|
||||
StatHelpText,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
} from "@chakra-ui/react";
|
||||
import type { GetBalanceResult } from "thirdweb/extensions/erc20";
|
||||
import { Card } from "tw-components";
|
||||
import { Stat } from "../../overview/components/stat-card";
|
||||
|
||||
interface TokenSupplyLayoutProps {
|
||||
isTokenSupplySuccess: boolean;
|
||||
export function TokenDetailsCardUI(props: {
|
||||
tokenSupply: GetBalanceResult | undefined;
|
||||
isOwnedBalanceSuccess: boolean;
|
||||
address: string | undefined;
|
||||
isWalletConnected: boolean;
|
||||
ownedBalance: GetBalanceResult | undefined;
|
||||
}
|
||||
|
||||
export const TokenSupplyLayout: React.FC<TokenSupplyLayoutProps> = ({
|
||||
isTokenSupplySuccess,
|
||||
tokenSupply,
|
||||
isOwnedBalanceSuccess,
|
||||
address,
|
||||
ownedBalance,
|
||||
}) => {
|
||||
}) {
|
||||
const { tokenSupply, isWalletConnected, ownedBalance } = props;
|
||||
return (
|
||||
<div className="flex flex-row gap-3 md:gap-6">
|
||||
<Card as={Stat}>
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>Total Supply</StatLabel>
|
||||
<Skeleton isLoaded={isTokenSupplySuccess}>
|
||||
<StatNumber>
|
||||
{tokenSupply?.displayValue} {tokenSupply?.symbol}
|
||||
</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
<Card as={Stat}>
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>Owned by you</StatLabel>
|
||||
<Skeleton isLoaded={isOwnedBalanceSuccess || !address}>
|
||||
<StatNumber>
|
||||
{address ? (
|
||||
<>
|
||||
{ownedBalance?.displayValue} {ownedBalance?.symbol}
|
||||
</>
|
||||
) : (
|
||||
<StatHelpText as="span">
|
||||
Connect your wallet to see your balance
|
||||
</StatHelpText>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
<Card as={Stat}>
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>Decimals</StatLabel>
|
||||
<Skeleton isLoaded={isTokenSupplySuccess}>
|
||||
<StatNumber>{tokenSupply?.decimals}</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<h2 className="border-b p-6 py-5 font-semibold text-xl tracking-tight">
|
||||
Token Details
|
||||
</h2>
|
||||
<div className="flex flex-col gap-5 p-6 lg:flex-row">
|
||||
<Stat
|
||||
label="Total Supply"
|
||||
isPending={!tokenSupply}
|
||||
value={
|
||||
tokenSupply
|
||||
? `${tokenSupply.displayValue} ${tokenSupply.symbol}`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
{isWalletConnected && (
|
||||
<Stat
|
||||
label="Owned by you"
|
||||
isPending={!ownedBalance}
|
||||
value={
|
||||
ownedBalance
|
||||
? `${ownedBalance?.displayValue} ${ownedBalance?.symbol}`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stat
|
||||
label="Decimals"
|
||||
isPending={!tokenSupply}
|
||||
value={tokenSupply ? tokenSupply?.decimals.toString() : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+17
-19
@@ -8,16 +8,18 @@ import {
|
||||
totalSupply,
|
||||
} from "thirdweb/extensions/erc20";
|
||||
import { useActiveAccount, useReadContract } from "thirdweb/react";
|
||||
import { TokenSupplyLayout } from "./supply-layout";
|
||||
import { TokenDetailsCardUI } from "./supply-layout";
|
||||
|
||||
interface TokenBalancesProps {
|
||||
contract: ThirdwebContract;
|
||||
}
|
||||
|
||||
export const TokenSupply: React.FC<TokenBalancesProps> = ({ contract }) => {
|
||||
export const TokenDetailsCard: React.FC<TokenBalancesProps> = ({
|
||||
contract,
|
||||
}) => {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
const tokenBalanceQuery = useReadContract(getBalance, {
|
||||
const ownedTokenBalanceQuery = useReadContract(getBalance, {
|
||||
contract,
|
||||
address: address || "",
|
||||
queryOptions: { enabled: !!address },
|
||||
@@ -25,7 +27,6 @@ export const TokenSupply: React.FC<TokenBalancesProps> = ({ contract }) => {
|
||||
|
||||
const tokenSupplyQuery = useReadContract(totalSupply, {
|
||||
contract,
|
||||
queryOptions: { enabled: !!address },
|
||||
});
|
||||
|
||||
const tokenMetadataQuery = useReadContract(getCurrencyMetadata, { contract });
|
||||
@@ -35,13 +36,7 @@ export const TokenSupply: React.FC<TokenBalancesProps> = ({ contract }) => {
|
||||
tokenMetadataQuery.data === undefined ||
|
||||
tokenSupplyQuery.data === undefined
|
||||
) {
|
||||
return {
|
||||
value: 0n,
|
||||
displayValue: "0.0",
|
||||
symbol: "LOA",
|
||||
decimals: 18,
|
||||
name: "Loading...",
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -51,18 +46,21 @@ export const TokenSupply: React.FC<TokenBalancesProps> = ({ contract }) => {
|
||||
tokenSupplyQuery.data,
|
||||
tokenMetadataQuery.data.decimals,
|
||||
),
|
||||
tokenAddress: contract.address,
|
||||
chainId: contract.chain.id,
|
||||
};
|
||||
}, [tokenMetadataQuery.data, tokenSupplyQuery.data]);
|
||||
}, [
|
||||
tokenMetadataQuery.data,
|
||||
tokenSupplyQuery.data,
|
||||
contract.address,
|
||||
contract.chain.id,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TokenSupplyLayout
|
||||
isTokenSupplySuccess={
|
||||
tokenSupplyQuery.isSuccess && tokenMetadataQuery.isSuccess
|
||||
}
|
||||
<TokenDetailsCardUI
|
||||
isWalletConnected={!!address}
|
||||
tokenSupply={tokenSupply}
|
||||
isOwnedBalanceSuccess={tokenBalanceQuery.isSuccess}
|
||||
address={address}
|
||||
ownedBalance={tokenBalanceQuery.data}
|
||||
ownedBalance={ownedTokenBalanceQuery.data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -6,15 +6,16 @@ import zeroGCTA from "./temp-assets/0gCTA.png";
|
||||
import zeroGBanner from "./temp-assets/0gLabsBanner.png";
|
||||
import alephZeroBaner from "./temp-assets/AlephZeroBanner.jpg";
|
||||
import alephZeroCTA from "./temp-assets/AlephZeroCTA.jpg";
|
||||
import ancient8Banner from "./temp-assets/ancient8-banner.png";
|
||||
import assetChainBanner from "./temp-assets/assetChainBanner.png";
|
||||
import assetChainCTABG from "./temp-assets/assetChainCTABanner.png";
|
||||
|
||||
import cotiBanner from "./temp-assets/COTI_Banner.jpg";
|
||||
import cotiCTA from "./temp-assets/COTI_CTA.jpg";
|
||||
import hemiSepoliaBanner from "./temp-assets/HemiBanner.png";
|
||||
import hemiSepoliaCTA from "./temp-assets/HemiCTA.png";
|
||||
import ancient8Banner from "./temp-assets/ancient8-banner.png";
|
||||
import appChainBanner from "./temp-assets/appChainBanner.jpg";
|
||||
// TEMPORARY
|
||||
import appchainCTA from "./temp-assets/appchainCTA.png";
|
||||
import assetChainBanner from "./temp-assets/assetChainBanner.png";
|
||||
import assetChainCTABG from "./temp-assets/assetChainCTABanner.png";
|
||||
import baseBanner from "./temp-assets/base-banner.jpeg";
|
||||
import superchainCTABG from "./temp-assets/cta-bg-superchain.png";
|
||||
import xaiCTABg from "./temp-assets/cta-bg-xai-connect.png";
|
||||
@@ -340,6 +341,30 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//COTI Mainnet
|
||||
2632500: {
|
||||
headerImgUrl: cotiBanner.src,
|
||||
about:
|
||||
"COTI is the fastest, lightest privacy layer in Web3, powered by the breakthrough cryptographic protocol Garbled Circuits. Build with privacy. Scale with speed.",
|
||||
cta: {
|
||||
backgroundImageUrl: cotiCTA.src,
|
||||
title: "The Fastest, Lightest Privacy Layer in Web3",
|
||||
buttonLink: "https://thirdweb.com/coti",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//COTI Testnet
|
||||
7082400: {
|
||||
headerImgUrl: cotiBanner.src,
|
||||
about:
|
||||
"COTI is the fastest, lightest privacy layer in Web3, powered by the breakthrough cryptographic protocol Garbled Circuits. Build with privacy. Scale with speed.",
|
||||
cta: {
|
||||
backgroundImageUrl: cotiCTA.src,
|
||||
title: "The Fastest, Lightest Privacy Layer in Web3",
|
||||
buttonLink: "https://thirdweb.com/coti",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//Donatuz
|
||||
42026: {
|
||||
headerImgUrl: thirdwebBanner.src,
|
||||
|
||||
@@ -33,6 +33,7 @@ export default async function DirectDeployPage(props: DirectDeployPageProps) {
|
||||
/>
|
||||
<DeployFormForUri
|
||||
contractMetadata={metadata}
|
||||
contractMetadataNoFee={metadata}
|
||||
modules={null}
|
||||
pathname={`/contracts/deploy/${params.compiler_uri}`}
|
||||
/>
|
||||
|
||||
+26
-12
@@ -3,6 +3,7 @@ import {
|
||||
fetchPublishedContractVersion,
|
||||
fetchPublishedContractVersions,
|
||||
} from "components/contract-components/fetch-contracts-with-versions";
|
||||
import { ZERO_FEE_VERSIONS } from "constants/fee-config";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
@@ -46,6 +47,10 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
|
||||
publishedContractVersions.find((v) => v.version === props.version) ||
|
||||
publishedContractVersions[0];
|
||||
|
||||
const publishedContractNoFee = publishedContractVersions.find(
|
||||
(v) => v.version === ZERO_FEE_VERSIONS[v.name],
|
||||
);
|
||||
|
||||
if (!publishedContract) {
|
||||
return null;
|
||||
}
|
||||
@@ -53,22 +58,30 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
|
||||
const moduleUris = modules
|
||||
.filter((m) => m !== null && m !== undefined)
|
||||
.map((m) => m.publishMetadataUri);
|
||||
const [contractMetadata, ...fetchedModules] = await Promise.all([
|
||||
fetchDeployMetadata({
|
||||
client,
|
||||
// force `ipfs://` prefix
|
||||
uri: publishedContract.publishMetadataUri.startsWith("ipfs://")
|
||||
? publishedContract.publishMetadataUri
|
||||
: `ipfs://${publishedContract.publishMetadataUri}`,
|
||||
}).catch(() => null),
|
||||
...(moduleUris || []).map((uri) =>
|
||||
const [contractMetadata, contractMetadataNoFee, ...fetchedModules] =
|
||||
await Promise.all([
|
||||
fetchDeployMetadata({
|
||||
client,
|
||||
// force `ipfs://` prefix
|
||||
uri: uri.startsWith("ipfs://") ? uri : `ipfs://${uri}`,
|
||||
uri: publishedContract.publishMetadataUri.startsWith("ipfs://")
|
||||
? publishedContract.publishMetadataUri
|
||||
: `ipfs://${publishedContract.publishMetadataUri}`,
|
||||
}).catch(() => null),
|
||||
),
|
||||
]);
|
||||
fetchDeployMetadata({
|
||||
client,
|
||||
// force `ipfs://` prefix
|
||||
uri: publishedContractNoFee?.publishMetadataUri.startsWith("ipfs://")
|
||||
? publishedContractNoFee.publishMetadataUri
|
||||
: `ipfs://${publishedContractNoFee?.publishMetadataUri}`,
|
||||
}).catch(() => null),
|
||||
...(moduleUris || []).map((uri) =>
|
||||
fetchDeployMetadata({
|
||||
client,
|
||||
// force `ipfs://` prefix
|
||||
uri: uri.startsWith("ipfs://") ? uri : `ipfs://${uri}`,
|
||||
}).catch(() => null),
|
||||
),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-8 pb-20">
|
||||
@@ -79,6 +92,7 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
|
||||
/>
|
||||
<DeployFormForUri
|
||||
contractMetadata={contractMetadata}
|
||||
contractMetadataNoFee={contractMetadataNoFee}
|
||||
modules={fetchedModules.filter((m) => m !== null)}
|
||||
pathname={`/${props.publisher}/${props.contract_id}${props.version ? `/${props.version}` : ""}/deploy`}
|
||||
/>
|
||||
|
||||
+3
-1
@@ -8,12 +8,13 @@ import { loginRedirect } from "../../../login/loginRedirect";
|
||||
|
||||
type DeployFormForUriProps = {
|
||||
contractMetadata: FetchDeployMetadataResult | null;
|
||||
contractMetadataNoFee: FetchDeployMetadataResult | null;
|
||||
modules: FetchDeployMetadataResult[] | null;
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
export async function DeployFormForUri(props: DeployFormForUriProps) {
|
||||
const { contractMetadata, modules, pathname } = props;
|
||||
const { contractMetadata, contractMetadataNoFee, modules, pathname } = props;
|
||||
|
||||
if (!contractMetadata) {
|
||||
return <div>Could not fetch metadata</div>;
|
||||
@@ -46,6 +47,7 @@ export async function DeployFormForUri(props: DeployFormForUriProps) {
|
||||
<ChakraProviderSetup>
|
||||
<CustomContractForm
|
||||
metadata={contractMetadata}
|
||||
metadataNoFee={contractMetadataNoFee}
|
||||
modules={modules?.filter((m) => m !== null)}
|
||||
jwt={authToken}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
|
||||
+19
-3
@@ -15,7 +15,11 @@ const UNTHREAD_API_KEY = process.env.UNTHREAD_API_KEY || "";
|
||||
|
||||
const planToCustomerId = {
|
||||
free: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
// treat starter as free
|
||||
starter: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
growth: process.env.UNTHREAD_GROWTH_TIER_ID as string,
|
||||
accelerate: process.env.UNTHREAD_ACCELERATE_TIER_ID as string,
|
||||
scale: process.env.UNTHREAD_SCALE_TIER_ID as string,
|
||||
pro: process.env.UNTHREAD_PRO_TIER_ID as string,
|
||||
} as const;
|
||||
|
||||
@@ -43,9 +47,17 @@ function prepareEmailBody(props: {
|
||||
name: string;
|
||||
extraInfoInput: Record<string, string>;
|
||||
walletAddress: string;
|
||||
telegramHandle: string;
|
||||
}) {
|
||||
const { extraInfoInput, email, walletAddress, product, name, markdownInput } =
|
||||
props;
|
||||
const {
|
||||
extraInfoInput,
|
||||
email,
|
||||
walletAddress,
|
||||
product,
|
||||
name,
|
||||
markdownInput,
|
||||
telegramHandle,
|
||||
} = props;
|
||||
// Update `markdown` to include the infos from the form
|
||||
const extraInfo = Object.keys(extraInfoInput)
|
||||
.filter((key) => key.startsWith("extraInfo_"))
|
||||
@@ -58,6 +70,7 @@ function prepareEmailBody(props: {
|
||||
.join("");
|
||||
const markdown = `# Email: ${email}
|
||||
# Name: ${name}
|
||||
# Telegram: ${telegramHandle}
|
||||
# Wallet address: ${walletAddress}
|
||||
# Product: ${product}
|
||||
${extraInfo}
|
||||
@@ -99,12 +112,14 @@ export async function createTicketAction(
|
||||
}
|
||||
|
||||
const customerId = isValidPlan(team.supportPlan)
|
||||
? planToCustomerId[team.supportPlan]
|
||||
? // fall back to "free" tier
|
||||
planToCustomerId[team.supportPlan] || planToCustomerId.free
|
||||
: // fallback to "free" tier
|
||||
planToCustomerId.free;
|
||||
|
||||
const product = formData.get("product")?.toString() || "";
|
||||
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
|
||||
const telegramHandle = formData.get("telegram")?.toString() || "";
|
||||
|
||||
const title = prepareEmailTitle(
|
||||
product,
|
||||
@@ -125,6 +140,7 @@ export async function createTicketAction(
|
||||
name: account.name || "",
|
||||
extraInfoInput: keyVal,
|
||||
walletAddress: walletAddress,
|
||||
telegramHandle: telegramHandle,
|
||||
});
|
||||
|
||||
const content = {
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
|
||||
import { SupportForm_TelegramInput } from "components/help/contact-forms/shared/SupportForm_TelegramInput";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
type ReactElement,
|
||||
@@ -155,6 +156,8 @@ export function CreateTicket(props: {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SupportForm_TelegramInput />
|
||||
|
||||
<ProductAreaSelection
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ImportModal } from "components/contract-components/import-contract/modal";
|
||||
import { DownloadIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTrack } from "../../../hooks/analytics/useTrack";
|
||||
|
||||
export function DeployedContractsPageHeader(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
@@ -33,16 +35,28 @@ export function DeployedContractsPageHeader(props: {
|
||||
<Button
|
||||
className="gap-2 bg-card"
|
||||
variant="outline"
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
action: "click",
|
||||
category: "contracts",
|
||||
label: "import-contract",
|
||||
});
|
||||
setImportModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Import contract
|
||||
</Button>
|
||||
<Button asChild className="gap-2">
|
||||
<Link href="/explore">
|
||||
<TrackedLinkTW
|
||||
href="/explore"
|
||||
category="contracts"
|
||||
label="deploy-contract"
|
||||
target="_blank"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Deploy contract
|
||||
</Link>
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ArrowUpRightIcon, DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ImportModal } from "../../../../components/contract-components/import-contract/modal";
|
||||
import { useTrack } from "../../../../hooks/analytics/useTrack";
|
||||
|
||||
export function DeployViaCLIOrImportCard(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -38,18 +40,27 @@ export function DeployViaCLIOrImportCard(props: {
|
||||
className="gap-2 bg-background lg:px-10"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/contracts/deploy/overview"
|
||||
target="_blank"
|
||||
category="contracts-banner"
|
||||
label="deploy-via-cli"
|
||||
>
|
||||
Deploy via CLI
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-background"
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
onClick={() => {
|
||||
setImportModalOpen(true);
|
||||
trackEvent({
|
||||
category: "contracts-banner",
|
||||
action: "click",
|
||||
label: "import-contract",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Import Contract
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const teamPlanToBadgeVariant: Record<
|
||||
Team["billingPlan"],
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
// gray
|
||||
free: "secondary",
|
||||
starter: "secondary",
|
||||
// yellow
|
||||
starter_legacy: "warning",
|
||||
growth_legacy: "warning",
|
||||
// green
|
||||
accelerate: "success",
|
||||
growth: "success",
|
||||
scale: "success",
|
||||
// blue
|
||||
pro: "default",
|
||||
};
|
||||
|
||||
function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
|
||||
if (plan === "growth_legacy") {
|
||||
return "Growth - Legacy";
|
||||
}
|
||||
if (plan === "starter_legacy") {
|
||||
return "Starter - Legacy";
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function TeamPlanBadge(props: {
|
||||
plan: Team["billingPlan"];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
props.plan === "free" || props.plan === "starter"
|
||||
? "secondary"
|
||||
: props.plan === "growth"
|
||||
? "success"
|
||||
: "default"
|
||||
}
|
||||
variant={teamPlanToBadgeVariant[props.plan]}
|
||||
className={cn("px-1.5 capitalize", props.className)}
|
||||
>
|
||||
{props.plan}
|
||||
{getTeamPlanBadgeLabel(props.plan)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-1
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Team } from "../../../../@/api/team";
|
||||
import { teamStub } from "../../../../stories/stubs";
|
||||
import { storybookLog } from "../../../../stories/utils";
|
||||
import { TeamOnboardingLayout } from "../onboarding-layout";
|
||||
@@ -36,8 +37,26 @@ export const GrowthPlan: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AcceleratePlan: Story = {
|
||||
args: {
|
||||
plan: "accelerate",
|
||||
},
|
||||
};
|
||||
|
||||
export const ScalePlan: Story = {
|
||||
args: {
|
||||
plan: "scale",
|
||||
},
|
||||
};
|
||||
|
||||
export const ProPlan: Story = {
|
||||
args: {
|
||||
plan: "pro",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
plan: "free" | "growth" | "starter";
|
||||
plan: Team["billingPlan"];
|
||||
}) {
|
||||
return (
|
||||
<TeamOnboardingLayout currentStep={2}>
|
||||
|
||||
@@ -7,12 +7,12 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react";
|
||||
@@ -74,15 +74,16 @@ export function InviteTeamMembersUI(props: {
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col">
|
||||
<Dialog open={showPlanModal} onOpenChange={setShowPlanModal}>
|
||||
<DialogContent className="lg:max-w-[750px]">
|
||||
<Sheet open={showPlanModal} onOpenChange={setShowPlanModal}>
|
||||
<SheetContent className="!max-w-[1300px] w-full overflow-auto">
|
||||
<InviteModalContent
|
||||
billingStatus={props.team.billingStatus}
|
||||
teamSlug={props.team.slug}
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
trackEvent={props.trackEvent}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<InviteSection
|
||||
inviteTeamMembers={props.inviteTeamMembers}
|
||||
@@ -144,17 +145,22 @@ export function InviteTeamMembersUI(props: {
|
||||
|
||||
function InviteModalContent(props: {
|
||||
teamSlug: string;
|
||||
billingStatus: Team["billingStatus"];
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
}) {
|
||||
const [planToShow, setPlanToShow] = useState<"starter" | "growth">("starter");
|
||||
const [planToShow, setPlanToShow] = useState<
|
||||
"starter" | "growth" | "accelerate" | "scale"
|
||||
>("growth");
|
||||
|
||||
const starterPlan = (
|
||||
<PricingCard
|
||||
billingPlan="starter"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started for free",
|
||||
title: "Get Started",
|
||||
type: "checkout",
|
||||
onClick() {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
@@ -163,10 +169,6 @@ function InviteModalContent(props: {
|
||||
plan: "starter",
|
||||
});
|
||||
},
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "starterPlan",
|
||||
},
|
||||
}}
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
/>
|
||||
@@ -175,13 +177,11 @@ function InviteModalContent(props: {
|
||||
const growthPlan = (
|
||||
<PricingCard
|
||||
billingPlan="growth"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started with Growth",
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "growthPlan",
|
||||
},
|
||||
title: "Get Started",
|
||||
type: "checkout",
|
||||
onClick() {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
@@ -190,33 +190,77 @@ function InviteModalContent(props: {
|
||||
plan: "growth",
|
||||
});
|
||||
},
|
||||
variant: "default",
|
||||
}}
|
||||
highlighted
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
const acceleratePlan = (
|
||||
<PricingCard
|
||||
billingPlan="accelerate"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get started",
|
||||
type: "checkout",
|
||||
onClick() {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "upgradePlan",
|
||||
label: "attempt",
|
||||
plan: "accelerate",
|
||||
});
|
||||
},
|
||||
}}
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
const scalePlan = (
|
||||
<PricingCard
|
||||
billingPlan="scale"
|
||||
billingStatus={props.billingStatus}
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get started",
|
||||
type: "checkout",
|
||||
onClick() {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "upgradePlan",
|
||||
label: "attempt",
|
||||
plan: "scale",
|
||||
});
|
||||
},
|
||||
}}
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Choose a plan</DialogTitle>
|
||||
|
||||
<DialogDescription className="leading-relaxed">
|
||||
<SheetHeader className="space-y-0.5">
|
||||
<SheetTitle className="text-left text-2xl tracking-tight">
|
||||
Choose a plan
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-left leading-relaxed">
|
||||
Get started with the free Starter plan or upgrade to Growth plan for
|
||||
increased limits and advanced features.{" "}
|
||||
<UnderlineLink href="https://thirdweb.com/pricing" target="_blank">
|
||||
Learn more about pricing
|
||||
</UnderlineLink>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="h-5" />
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden grid-cols-1 gap-6 md:grid-cols-2 md:gap-4 lg:grid">
|
||||
<div className="hidden grid-cols-1 gap-6 md:grid-cols-4 md:gap-4 lg:grid">
|
||||
{starterPlan}
|
||||
{growthPlan}
|
||||
{acceleratePlan}
|
||||
{scalePlan}
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
@@ -233,11 +277,23 @@ function InviteModalContent(props: {
|
||||
onClick: () => setPlanToShow("growth"),
|
||||
isActive: planToShow === "growth",
|
||||
},
|
||||
{
|
||||
name: "Accelerate",
|
||||
onClick: () => setPlanToShow("accelerate"),
|
||||
isActive: planToShow === "accelerate",
|
||||
},
|
||||
{
|
||||
name: "Scale",
|
||||
onClick: () => setPlanToShow("scale"),
|
||||
isActive: planToShow === "scale",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="h-4" />
|
||||
{planToShow === "starter" && starterPlan}
|
||||
{planToShow === "growth" && growthPlan}
|
||||
{planToShow === "accelerate" && acceleratePlan}
|
||||
{planToShow === "scale" && scalePlan}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -155,6 +155,8 @@ export function TeamInfoFormUI(props: {
|
||||
});
|
||||
}
|
||||
|
||||
const maxTeamNameLength = 32;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card ">
|
||||
<Form {...form}>
|
||||
@@ -199,6 +201,7 @@ export function TeamInfoFormUI(props: {
|
||||
placeholder="Company Inc."
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
maxLength={maxTeamNameLength}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function Page(props: {
|
||||
session={session}
|
||||
type="new-chat"
|
||||
account={account}
|
||||
initialPrompt={undefined}
|
||||
initialParams={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function Page() {
|
||||
session={undefined}
|
||||
type="new-chat"
|
||||
account={account}
|
||||
initialPrompt={undefined}
|
||||
initialParams={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,18 @@ export function ChatBar(props: {
|
||||
sendMessage: (message: string) => void;
|
||||
isChatStreaming: boolean;
|
||||
abortChatStream: () => void;
|
||||
prefillMessage: string | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [message, setMessage] = useState(props.prefillMessage || "");
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card p-2",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
<AutoResizeTextarea
|
||||
placeholder={"Ask Nebula"}
|
||||
|
||||
@@ -29,7 +29,13 @@ export function ChatPageContent(props: {
|
||||
authToken: string;
|
||||
type: "landing" | "new-chat";
|
||||
account: Account;
|
||||
initialPrompt: string | undefined;
|
||||
initialParams:
|
||||
| {
|
||||
q: string | undefined;
|
||||
chainIds: number[];
|
||||
wallet: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const address = useActiveAccount()?.address;
|
||||
const client = useThirdwebClient(props.authToken);
|
||||
@@ -85,8 +91,12 @@ export function ChatPageContent(props: {
|
||||
>(() => {
|
||||
const contextRes = props.session?.context;
|
||||
const value: NebulaContext = {
|
||||
chainIds: contextRes?.chain_ids || null,
|
||||
walletAddress: contextRes?.wallet_address || null,
|
||||
chainIds:
|
||||
contextRes?.chain_ids ||
|
||||
props.initialParams?.chainIds.map((x) => x.toString()) ||
|
||||
[],
|
||||
walletAddress:
|
||||
contextRes?.wallet_address || props.initialParams?.wallet || null,
|
||||
};
|
||||
|
||||
return value;
|
||||
@@ -118,18 +128,21 @@ export function ChatPageContent(props: {
|
||||
walletAddress: null,
|
||||
};
|
||||
|
||||
// Only set wallet address from connected wallet
|
||||
updatedContextFilters.walletAddress = address || null;
|
||||
if (!updatedContextFilters.walletAddress && address) {
|
||||
updatedContextFilters.walletAddress = address;
|
||||
}
|
||||
|
||||
// if we have last used chains in storage, continue using them
|
||||
try {
|
||||
const lastUsedChainIds = getLastUsedChainIds();
|
||||
if (lastUsedChainIds) {
|
||||
updatedContextFilters.chainIds = lastUsedChainIds;
|
||||
return updatedContextFilters;
|
||||
if (updatedContextFilters.chainIds?.length === 0) {
|
||||
// if we have last used chains in storage, continue using them
|
||||
try {
|
||||
const lastUsedChainIds = getLastUsedChainIds();
|
||||
if (lastUsedChainIds) {
|
||||
updatedContextFilters.chainIds = lastUsedChainIds;
|
||||
return updatedContextFilters;
|
||||
}
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
|
||||
return updatedContextFilters;
|
||||
@@ -148,7 +161,9 @@ export function ChatPageContent(props: {
|
||||
(sessionId: string) => {
|
||||
_setSessionId(sessionId);
|
||||
// update page URL without reloading
|
||||
window.history.replaceState({}, "", `/chat/${sessionId}`);
|
||||
// THIS DOES NOT WORK ANYMORE!! - NEXT JS IS MONKEY PATCHING THIS TOO
|
||||
// Until we find a better solution, we are just not gonna update the URL
|
||||
// window.history.replaceState({}, "", `/chat/${sessionId}`);
|
||||
|
||||
// if the current page is landing page, link to /chat
|
||||
// if current page is new /chat page, link to landing page
|
||||
@@ -176,10 +191,6 @@ export function ChatPageContent(props: {
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (message: string) => {
|
||||
if (!address) {
|
||||
setShowConnectModal(true);
|
||||
return;
|
||||
}
|
||||
setUserHasSubmittedMessage(true);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
@@ -191,12 +202,11 @@ export function ChatPageContent(props: {
|
||||
},
|
||||
]);
|
||||
|
||||
const lowerCaseMessage = message.toLowerCase();
|
||||
// handle hardcoded replies first
|
||||
const lowerCaseMessage = message.toLowerCase();
|
||||
const interceptedReply = examplePrompts.find(
|
||||
(prompt) => prompt.message.toLowerCase() === lowerCaseMessage,
|
||||
)?.interceptedReply;
|
||||
|
||||
if (interceptedReply) {
|
||||
// slight delay to match other response times
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
@@ -220,8 +230,6 @@ export function ChatPageContent(props: {
|
||||
currentSessionId = session.id;
|
||||
}
|
||||
|
||||
let requestIdForMessage = "";
|
||||
|
||||
// add this session on sidebar
|
||||
if (messages.length === 0) {
|
||||
const prevValue = newSessionsStore.getValue();
|
||||
@@ -238,131 +246,29 @@ export function ChatPageContent(props: {
|
||||
|
||||
setChatAbortController(abortController);
|
||||
|
||||
await promptNebula({
|
||||
await handleNebulaPrompt({
|
||||
abortController,
|
||||
message: message,
|
||||
message,
|
||||
sessionId: currentSessionId,
|
||||
authToken: props.authToken,
|
||||
handleStream(res) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.event === "init") {
|
||||
requestIdForMessage = res.data.request_id;
|
||||
}
|
||||
|
||||
if (res.event === "delta") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// if last message is from chat, append to it
|
||||
if (lastMessage?.type === "assistant") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: lastMessage.text + res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// otherwise, add a new message
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "presence") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ text: res.data.data, type: "presence" },
|
||||
];
|
||||
}
|
||||
// otherwise, add a new message
|
||||
return [...prev, { text: res.data.data, type: "presence" }];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "action") {
|
||||
if (res.type === "sign_transaction") {
|
||||
setMessages((prev) => {
|
||||
let prevMessages = prev;
|
||||
// if last message is presence, remove it
|
||||
if (
|
||||
prevMessages[prevMessages.length - 1]?.type === "presence"
|
||||
) {
|
||||
prevMessages = prevMessages.slice(0, -1);
|
||||
}
|
||||
|
||||
return [
|
||||
...prevMessages,
|
||||
{
|
||||
type: "send_transaction",
|
||||
data: res.data,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
context: contextFilters,
|
||||
setMessages,
|
||||
contextFilters: contextFilters,
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
|
||||
setMessages((prev) => {
|
||||
const newMessages = prev.slice(
|
||||
0,
|
||||
prev[prev.length - 1]?.type === "presence" ? -1 : undefined,
|
||||
);
|
||||
|
||||
// add error message
|
||||
newMessages.push({
|
||||
text: `Error: ${error instanceof Error ? error.message : "Failed to execute command"}`,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
return newMessages;
|
||||
handleNebulaPromptError({
|
||||
error,
|
||||
setMessages,
|
||||
});
|
||||
} finally {
|
||||
setIsChatStreaming(false);
|
||||
setEnableAutoScroll(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
sessionId,
|
||||
contextFilters,
|
||||
props.authToken,
|
||||
messages.length,
|
||||
initSession,
|
||||
address,
|
||||
],
|
||||
[sessionId, contextFilters, props.authToken, messages.length, initSession],
|
||||
);
|
||||
|
||||
const hasDoneAutoPrompt = useRef(false);
|
||||
@@ -370,16 +276,19 @@ export function ChatPageContent(props: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (
|
||||
props.initialPrompt &&
|
||||
props.initialParams?.q &&
|
||||
messages.length === 0 &&
|
||||
!hasDoneAutoPrompt.current
|
||||
) {
|
||||
hasDoneAutoPrompt.current = true;
|
||||
handleSendMessage(props.initialPrompt);
|
||||
handleSendMessage(props.initialParams.q);
|
||||
}
|
||||
}, [props.initialPrompt, messages.length, handleSendMessage]);
|
||||
}, [props.initialParams?.q, messages.length, handleSendMessage]);
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
const showEmptyState =
|
||||
!userHasSubmittedMessage &&
|
||||
messages.length === 0 &&
|
||||
!props.initialParams?.q;
|
||||
|
||||
const handleUpdateContextFilters = async (
|
||||
values: NebulaContext | undefined,
|
||||
@@ -412,7 +321,10 @@ export function ChatPageContent(props: {
|
||||
<div className="relative flex grow flex-col overflow-hidden rounded-lg pb-6">
|
||||
{showEmptyState ? (
|
||||
<div className="fade-in-0 container flex max-w-[800px] grow animate-in flex-col justify-center">
|
||||
<EmptyStateChatPageContent sendMessage={handleSendMessage} />
|
||||
<EmptyStateChatPageContent
|
||||
sendMessage={handleSendMessage}
|
||||
prefillMessage={props.initialParams?.q}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
|
||||
@@ -430,6 +342,7 @@ export function ChatPageContent(props: {
|
||||
|
||||
<div className="container max-w-[800px]">
|
||||
<ChatBar
|
||||
prefillMessage={undefined}
|
||||
sendMessage={handleSendMessage}
|
||||
isChatStreaming={isChatStreaming}
|
||||
abortChatStream={() => {
|
||||
@@ -452,9 +365,11 @@ export function ChatPageContent(props: {
|
||||
</div>
|
||||
<aside className="hidden w-[360px] flex-col border-l bg-card pt-4 xl:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="font-semibold text-lg tracking-tight">Context</h3>
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Settings
|
||||
</h3>
|
||||
<p className="mb-5 text-muted-foreground text-sm">
|
||||
Provide context to Nebula for your prompts
|
||||
Modify Nebula to suit your needs
|
||||
</p>
|
||||
</div>
|
||||
<ContextFiltersForm
|
||||
@@ -530,3 +445,136 @@ function getLastUsedChainIds(): string[] | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleNebulaPrompt(params: {
|
||||
abortController: AbortController;
|
||||
message: string;
|
||||
sessionId: string;
|
||||
authToken: string;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
contextFilters: NebulaContext | undefined;
|
||||
}) {
|
||||
const {
|
||||
abortController,
|
||||
message,
|
||||
sessionId,
|
||||
authToken,
|
||||
setMessages,
|
||||
contextFilters,
|
||||
} = params;
|
||||
let requestIdForMessage = "";
|
||||
|
||||
await promptNebula({
|
||||
abortController,
|
||||
message,
|
||||
sessionId,
|
||||
authToken,
|
||||
handleStream(res) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.event === "init") {
|
||||
requestIdForMessage = res.data.request_id;
|
||||
}
|
||||
|
||||
if (res.event === "delta") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// if last message is from chat, append to it
|
||||
if (lastMessage?.type === "assistant") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
text: lastMessage.text + res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// otherwise, add a new message
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
text: res.data.v,
|
||||
type: "assistant",
|
||||
request_id: requestIdForMessage,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "presence") {
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
// if last message is presence, overwrite it
|
||||
if (lastMessage?.type === "presence") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ text: res.data.data, type: "presence" },
|
||||
];
|
||||
}
|
||||
// otherwise, add a new message
|
||||
return [...prev, { text: res.data.data, type: "presence" }];
|
||||
});
|
||||
}
|
||||
|
||||
if (res.event === "action") {
|
||||
if (res.type === "sign_transaction") {
|
||||
setMessages((prev) => {
|
||||
let prevMessages = prev;
|
||||
// if last message is presence, remove it
|
||||
if (prevMessages[prevMessages.length - 1]?.type === "presence") {
|
||||
prevMessages = prevMessages.slice(0, -1);
|
||||
}
|
||||
|
||||
return [
|
||||
...prevMessages,
|
||||
{
|
||||
type: "send_transaction",
|
||||
data: res.data,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
context: contextFilters,
|
||||
});
|
||||
}
|
||||
|
||||
export function handleNebulaPromptError(params: {
|
||||
error: unknown;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
}) {
|
||||
const { error, setMessages } = params;
|
||||
console.error(error);
|
||||
|
||||
setMessages((prev) => {
|
||||
const newMessages = prev.slice(
|
||||
0,
|
||||
prev[prev.length - 1]?.type === "presence" ? -1 : undefined,
|
||||
);
|
||||
|
||||
// add error message
|
||||
newMessages.push({
|
||||
text: `Error: ${error instanceof Error ? error.message : "Failed to execute command"}`,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"use client";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
FileCode2Icon,
|
||||
MessageSquareShareIcon,
|
||||
MessagesSquareIcon,
|
||||
SquareDashedBottomCodeIcon,
|
||||
TextIcon,
|
||||
} from "lucide-react";
|
||||
import { ChevronRightIcon, FileCode2Icon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { TruncatedSessionInfo } from "../api/types";
|
||||
import { useNewChatPageLink } from "../hooks/useNewChatPageLink";
|
||||
@@ -29,54 +22,47 @@ export function ChatSidebar(props: {
|
||||
const newChatPage = useNewChatPageLink();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-2">
|
||||
<div className="flex items-center justify-start gap-3 p-2 lg:justify-between">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-start gap-3 p-4 lg:justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<NebulaIcon className="size-8 text-foreground" aria-label="Nebula" />
|
||||
<span className="font-semibold text-lg tracking-tight">
|
||||
Playground
|
||||
</span>
|
||||
<span className="font-semibold text-lg tracking-tight">Nebula</span>
|
||||
</Link>
|
||||
|
||||
<Badge variant="secondary" className="gap-1 py-1">
|
||||
Alpha
|
||||
Beta
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<div className="h-1" />
|
||||
|
||||
<div className="flex flex-col gap-2 px-2">
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
<Button asChild variant="outline" className="w-full gap-2 rounded-lg">
|
||||
<Link href={newChatPage}>New Chat</Link>
|
||||
<Link href={newChatPage}>
|
||||
<PlusIcon className="size-4" />
|
||||
New Chat
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/nebula/api-reference"
|
||||
icon={FileCode2Icon}
|
||||
label="API Reference"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SidebarIconLink
|
||||
href="/chat/history"
|
||||
icon={MessagesSquareIcon}
|
||||
label="All Chats"
|
||||
/>
|
||||
<div className="h-5" />
|
||||
|
||||
{sessionsToShow.length > 0 && (
|
||||
<ScrollShadow
|
||||
className="my-3 flex-1 border-t border-dashed pt-2"
|
||||
scrollableClassName="max-h-full"
|
||||
shadowColor="transparent"
|
||||
shadowClassName="z-10"
|
||||
>
|
||||
<div className="flex-1 overflow-auto border-t border-dashed p-2 pt-2">
|
||||
<div className="flex items-center justify-between px-2 py-3">
|
||||
<h3 className="text-muted-foreground text-xs">Recent Chats</h3>
|
||||
{sessionsToShow.length < sessions.length && (
|
||||
<Link
|
||||
href="/chat/history"
|
||||
className="flex items-center gap-1 rounded-full text-foreground text-xs hover:underline"
|
||||
>
|
||||
View All
|
||||
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="px-2 py-3 text-muted-foreground text-xs">
|
||||
Recent Chats
|
||||
</h3>
|
||||
{sessionsToShow.map((session) => {
|
||||
return (
|
||||
<ChatSidebarLink
|
||||
@@ -88,27 +74,13 @@ export function ChatSidebar(props: {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 border-y border-dashed py-3">
|
||||
<SidebarIconLink
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLSeM3fJRyywihRZUF1fiTNKEpJ_AzAcohRwXPpLr_3zxQ6W-tg/viewform?usp=sharing"
|
||||
icon={MessageSquareShareIcon}
|
||||
label="Take our quick survey!"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/changelog"
|
||||
icon={TextIcon}
|
||||
label="Changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<div className="mb-3 border-y border-dashed px-2 py-3">
|
||||
<SidebarIconLink
|
||||
href="https://portal.thirdweb.com/nebula"
|
||||
icon={SquareDashedBottomCodeIcon}
|
||||
icon={FileCode2Icon}
|
||||
label="Documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
@@ -130,13 +102,12 @@ function SidebarIconLink(props: {
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<Button asChild variant="ghost">
|
||||
<Link
|
||||
href={props.href}
|
||||
target={props.target}
|
||||
className="!justify-start !px-3 w-full gap-2.5 rounded-lg text-left"
|
||||
prefetch={false}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm"
|
||||
>
|
||||
<Link href={props.href} target={props.target} prefetch={false}>
|
||||
<props.icon className="size-4" />
|
||||
{props.label}
|
||||
</Link>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ChatSidebarLink(props: {
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="!h-auto !w-full items-center justify-between px-2 text-left text-sm hover:bg-transparent"
|
||||
className="h-auto w-full items-center justify-between px-2 py-1.5 text-left text-sm hover:bg-transparent"
|
||||
>
|
||||
<NavLink href={`/chat/${props.sessionId}`} activeClassName="bg-muted">
|
||||
<span className="line-clamp-1 block truncate">{props.title}</span>
|
||||
@@ -61,7 +61,7 @@ export function ChatSidebarLink(props: {
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="-translate-y-1/2 !bg-background absolute top-1/2 right-1 z-10 h-auto w-auto p-1.5 opacity-0 group-hover:opacity-100"
|
||||
className="-translate-y-1/2 absolute top-1/2 right-1 z-10 h-auto w-auto bg-accent p-1 opacity-0 hover:bg-zinc-300 group-hover:opacity-100 dark:hover:bg-zinc-700"
|
||||
variant="ghost"
|
||||
>
|
||||
<EllipsisIcon className="size-4" />
|
||||
|
||||
@@ -27,6 +27,7 @@ function Story() {
|
||||
abortChatStream={() => {}}
|
||||
isChatStreaming={false}
|
||||
sendMessage={() => {}}
|
||||
prefillMessage={undefined}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -35,6 +36,16 @@ function Story() {
|
||||
abortChatStream={() => {}}
|
||||
isChatStreaming={true}
|
||||
sendMessage={() => {}}
|
||||
prefillMessage={undefined}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Prefilled Message">
|
||||
<ChatBar
|
||||
abortChatStream={() => {}}
|
||||
isChatStreaming={false}
|
||||
sendMessage={() => {}}
|
||||
prefillMessage="This is a prefilled message"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ export function Chats(props: {
|
||||
client: ThirdwebClient;
|
||||
setEnableAutoScroll: (enable: boolean) => void;
|
||||
enableAutoScroll: boolean;
|
||||
useSmallText?: boolean;
|
||||
}) {
|
||||
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -99,7 +100,7 @@ export function Chats(props: {
|
||||
>
|
||||
<ScrollShadow
|
||||
className="flex-1"
|
||||
scrollableClassName="max-h-full"
|
||||
scrollableClassName="max-h-full overscroll-contain"
|
||||
shadowColor="hsl(var(--background))"
|
||||
shadowClassName="z-[1]"
|
||||
>
|
||||
@@ -110,24 +111,20 @@ export function Chats(props: {
|
||||
props.isChatStreaming && index === props.messages.length - 1;
|
||||
return (
|
||||
<div
|
||||
className="fade-in-0 min-w-0 animate-in pt-1 text-sm duration-300 lg:text-base"
|
||||
className={cn(
|
||||
"fade-in-0 min-w-0 animate-in pt-1 text-sm duration-300 lg:text-base",
|
||||
props.useSmallText && "lg:text-sm",
|
||||
)}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is the unique key
|
||||
key={index}
|
||||
>
|
||||
{message.type === "user" ? (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="max-w-[80%] overflow-auto rounded-xl border bg-card px-4 py-2">
|
||||
<MarkdownRenderer
|
||||
skipHtml
|
||||
markdownText={message.text}
|
||||
code={{
|
||||
ignoreFormattingErrors: true,
|
||||
className: "bg-transparent",
|
||||
}}
|
||||
className="text-foreground [&>*:last-child]:mb-0"
|
||||
p={{ className: "text-foreground leading-normal" }}
|
||||
li={{ className: "text-foreground" }}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
<StyledMarkdownRenderer
|
||||
text={message.text}
|
||||
isMessagePending={isMessagePending}
|
||||
type="user"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,18 +158,10 @@ export function Chats(props: {
|
||||
<div className="min-w-0 grow">
|
||||
<ScrollShadow className="rounded-lg">
|
||||
{message.type === "assistant" ? (
|
||||
<MarkdownRenderer
|
||||
skipHtml
|
||||
markdownText={message.text}
|
||||
code={{
|
||||
disableCodeHighlight: isMessagePending,
|
||||
ignoreFormattingErrors: true,
|
||||
}}
|
||||
className="text-foreground [&>*:last-child]:mb-0"
|
||||
p={{
|
||||
className: "text-foreground",
|
||||
}}
|
||||
li={{ className: "text-foreground" }}
|
||||
<StyledMarkdownRenderer
|
||||
text={message.text}
|
||||
isMessagePending={isMessagePending}
|
||||
type="assistant"
|
||||
/>
|
||||
) : message.type === "error" ? (
|
||||
<div className="rounded-xl border bg-card px-4 py-2 text-destructive-text leading-normal">
|
||||
@@ -269,10 +258,14 @@ function MessageActions(props: {
|
||||
const sendBadRating = useMutation({
|
||||
mutationFn: () => sendRating("bad"),
|
||||
onSuccess() {
|
||||
toast.info("Thanks for the feedback!");
|
||||
toast.info("Thanks for the feedback!", {
|
||||
position: "top-right",
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
toast.error("Failed to send feedback");
|
||||
toast.error("Failed to send feedback", {
|
||||
position: "top-right",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -332,3 +325,29 @@ function MessageActions(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledMarkdownRenderer(props: {
|
||||
text: string;
|
||||
isMessagePending: boolean;
|
||||
type: "assistant" | "user";
|
||||
}) {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
skipHtml
|
||||
markdownText={props.text}
|
||||
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
code={{
|
||||
ignoreFormattingErrors: true,
|
||||
className: "bg-transparent",
|
||||
}}
|
||||
p={{
|
||||
className:
|
||||
props.type === "assistant"
|
||||
? "text-foreground leading-loose"
|
||||
: "text-foreground leading-normal",
|
||||
}}
|
||||
li={{ className: "text-foreground" }}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ export default function ContextFiltersButton(props: {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-1">
|
||||
<DialogTitle className="font-semibold text-xl">Context</DialogTitle>
|
||||
<DialogTitle className="font-semibold text-xl">Settings</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Provide context to Nebula for your prompts
|
||||
Modify Nebula to suit your needs
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -189,6 +189,7 @@ export function ContextFiltersForm(props: {
|
||||
<FormLabel>Chain IDs</FormLabel>
|
||||
<FormControl>
|
||||
<MultiNetworkSelector
|
||||
hideTestnets
|
||||
disableChainId
|
||||
className="bg-background"
|
||||
selectedChainIds={form
|
||||
@@ -197,7 +198,9 @@ export function ContextFiltersForm(props: {
|
||||
.filter(Boolean)
|
||||
.map(Number)}
|
||||
onChange={(values) => {
|
||||
form.setValue("chainIds", values.join(","));
|
||||
form.setValue("chainIds", values.join(","), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
priorityChains={[
|
||||
1, // ethereum
|
||||
@@ -250,13 +253,20 @@ export function ContextFiltersForm(props: {
|
||||
</DialogClose>
|
||||
)}
|
||||
<Button
|
||||
className="min-w-24 gap-2"
|
||||
className="min-w-24 gap-2 disabled:text-muted-foreground disabled:opacity-100"
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={updateMutation.isPending}
|
||||
variant={
|
||||
props.modal
|
||||
? "default"
|
||||
: form.formState.isDirty
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
disabled={updateMutation.isPending || !form.formState.isDirty}
|
||||
>
|
||||
{updateMutation.isPending && <Spinner className="size-4" />}
|
||||
Update Context
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+16
-3
@@ -15,13 +15,26 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
args: {
|
||||
prefillMessage: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
export const PrefilledMessage: Story = {
|
||||
args: {
|
||||
prefillMessage: "This is a prefilled message",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
prefillMessage: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="container flex max-w-[800px] grow flex-col justify-center overflow-hidden">
|
||||
<EmptyStateChatPageContent sendMessage={() => {}} />
|
||||
<EmptyStateChatPageContent
|
||||
sendMessage={() => {}}
|
||||
prefillMessage={props.prefillMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,22 +8,22 @@ import { ChatBar } from "./ChatBar";
|
||||
|
||||
export function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: string) => void;
|
||||
prefillMessage: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-10 lg:py-16">
|
||||
<div className="relative py-10">
|
||||
<FancyBorders />
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-lg border p-1">
|
||||
<div className="rounded border bg-muted p-1.5">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5" />
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
with the blockchain today?
|
||||
How can I help you <br /> onchain today?
|
||||
</h1>
|
||||
<div className="h-5" />
|
||||
<div className="mx-auto max-w-[600px]">
|
||||
@@ -33,6 +33,7 @@ export function EmptyStateChatPageContent(props: {
|
||||
abortChatStream={() => {
|
||||
// the page will switch so, no need to handle abort here
|
||||
}}
|
||||
prefillMessage={props.prefillMessage}
|
||||
/>
|
||||
<div className="h-5" />
|
||||
<div className="flex flex-wrap justify-center gap-2.5">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user