Compare commits
147
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
ad7f78ca0b | ||
|
|
0967a5d53a | ||
|
|
d93765ac54 | ||
|
|
169ae22a86 | ||
|
|
04d2bc72e8 | ||
|
|
1a236fd103 | ||
|
|
4691343c7d | ||
|
|
93f225b8a3 | ||
|
|
e9649a2b53 | ||
|
|
4d39fb6ee3 | ||
|
|
1e092a7bff | ||
|
|
80ee3d6670 | ||
|
|
a7fd4e51a3 | ||
|
|
61583cf6f3 | ||
|
|
cf02af06a4 | ||
|
|
6001f2487e | ||
|
|
3f21445369 | ||
|
|
9e8fd89ec0 | ||
|
|
95a87530e3 | ||
|
|
5faaf1be48 | ||
|
|
4847ada819 | ||
|
|
08eea95f9b | ||
|
|
737155ba21 | ||
|
|
73ae5817f2 | ||
|
|
6a0704a19b | ||
|
|
2ef8f2a371 | ||
|
|
d6373b2c67 | ||
|
|
7eaf86a607 | ||
|
|
5788cbdbad | ||
|
|
d9a39cde33 | ||
|
|
62c8e7c0c4 | ||
|
|
f618c5308c | ||
|
|
04943d730b | ||
|
|
2606da6d87 | ||
|
|
9cf137660a | ||
|
|
58ab8c2ee7 | ||
|
|
447a519ddc | ||
|
|
a9b7633d05 | ||
|
|
38e089a5f3 | ||
|
|
ef2f86cff0 | ||
|
|
8fdef82147 | ||
|
|
cbb15692a7 | ||
|
|
1d23e5cd9c | ||
|
|
a17d893811 | ||
|
|
0dcde077f5 | ||
|
|
05a041fac3 | ||
|
|
3d140eed97 | ||
|
|
a5f005fff0 | ||
|
|
fb70cab6d4 | ||
|
|
cd9ea69c13 | ||
|
|
dc892bd136 | ||
|
|
3d7c009d72 | ||
|
|
6dbed0d4fc | ||
|
|
adee1903d5 | ||
|
|
a1d785b8ed |
@@ -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:
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@c016c38f26f2c4a6eb3662679143614a254263fd # v2.3.0
|
||||
uses: biomejs/setup-biome@f382a98e582959e6aaac8e5f8b17b31749018780 # v2.5.0
|
||||
with:
|
||||
version: latest
|
||||
|
||||
@@ -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*
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "@/styles/globals.css";
|
||||
import "../src/global.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
@@ -81,23 +81,18 @@ function StoryLayout(props: {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex min-h-dvh min-w-0 flex-col bg-background text-foreground">
|
||||
<div className="flex justify-end gap-2 border-b p-4">
|
||||
<div className="fixed right-0 bottom-0 z-50 flex justify-end gap-2 p-4">
|
||||
<Button
|
||||
onClick={() => setTheme("dark")}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
size="sm"
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
variant="outline"
|
||||
className="h-auto w-auto shrink-0 rounded-full p-2"
|
||||
>
|
||||
<MoonIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setTheme("light")}
|
||||
size="sm"
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
className="h-auto w-auto shrink-0 rounded-full p-2"
|
||||
>
|
||||
<SunIcon className="size-4" />
|
||||
{theme === "dark" ? (
|
||||
<MoonIcon className="size-4" />
|
||||
) : (
|
||||
<SunIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,12 +49,13 @@ module.exports = [
|
||||
"/templates/:template_slug",
|
||||
// -- learn --
|
||||
"/learn",
|
||||
"/learn/tutorials",
|
||||
"/learn/tutorials/:tutorial_slug",
|
||||
"/learn/guides",
|
||||
"/learn/guides/:guide_slug",
|
||||
"/learn/courses",
|
||||
"/learn/glossary",
|
||||
"/learn/glossary/:glossary_slug",
|
||||
// -- faucets --
|
||||
"/faucets",
|
||||
// -- brand kit --
|
||||
"/brand-kit",
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
+50
-48
@@ -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,8 +68,8 @@
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.0",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.483.0",
|
||||
"next": "15.2.3",
|
||||
"lucide-react": "0.487.0",
|
||||
"next": "15.2.4",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
@@ -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:*",
|
||||
@@ -106,41 +108,41 @@
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@next/bundle-analyzer": "15.2.3",
|
||||
"@next/eslint-plugin-next": "15.2.3",
|
||||
"@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-storybook": "0.11.6",
|
||||
"knip": "5.46.0",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
|
||||
"eslint-plugin-storybook": "0.12.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +361,19 @@ async function redirects() {
|
||||
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
// all /learn/tutorials (and sub-routes) -> /learn/guides
|
||||
{
|
||||
source: "/learn/tutorials/:path*",
|
||||
destination: "/learn/guides/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/learn/tutorials",
|
||||
destination: "/learn/guides",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
...legacyDashboardToTeamRedirects,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -270,9 +270,10 @@ type ActiveStatus = {
|
||||
ecosystemWallet: boolean;
|
||||
};
|
||||
|
||||
export async function isProjectActive(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<ActiveStatus> {
|
||||
export async function isProjectActive(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}): Promise<ActiveStatus> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/active-usage?${searchParams.toString()}`,
|
||||
@@ -345,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: {
|
||||
@@ -355,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,113 @@
|
||||
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: number;
|
||||
};
|
||||
averageRate: {
|
||||
date: string;
|
||||
averageRate: number;
|
||||
includedCount: number;
|
||||
rateLimitedCount: number;
|
||||
overageCount: number;
|
||||
}[];
|
||||
totalCounts: {
|
||||
includedCount: number;
|
||||
rateLimitedCount: number;
|
||||
overageCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
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"],
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ import { GradientAvatar } from "./GradientAvatar";
|
||||
const meta = {
|
||||
title: "blocks/Avatars/GradientAvatar",
|
||||
component: Story,
|
||||
parameters: {},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
@@ -22,14 +21,14 @@ const client = getThirdwebClient();
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 p-10">
|
||||
<p> All images below are set with size-6 className </p>
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<p> All images below are set with size-20 className </p>
|
||||
|
||||
<BadgeContainer label="No Src, No id - Skeleton">
|
||||
<GradientAvatar
|
||||
id={undefined}
|
||||
src={undefined}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -38,7 +37,7 @@ function Story() {
|
||||
<GradientAvatar
|
||||
id={"foo"}
|
||||
src={undefined}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -47,7 +46,7 @@ function Story() {
|
||||
<GradientAvatar
|
||||
id={"foo"}
|
||||
src={""}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -56,7 +55,7 @@ function Story() {
|
||||
<GradientAvatar
|
||||
id={"bar"}
|
||||
src={""}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -65,7 +64,7 @@ function Story() {
|
||||
<GradientAvatar
|
||||
src="invalid-src"
|
||||
id={undefined}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -74,7 +73,7 @@ function Story() {
|
||||
<GradientAvatar
|
||||
src="https://picsum.photos/200/300"
|
||||
id={undefined}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -83,7 +82,7 @@ function Story() {
|
||||
<GradientAvatar
|
||||
src="ipfs://QmZbeJYEs7kCJHyQxjxU2SJUtjSAr4m87wzJFJUyWomKdj/Smily.svg"
|
||||
id={undefined}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -123,14 +122,14 @@ function ToggleTest() {
|
||||
<GradientAvatar
|
||||
src={data?.src}
|
||||
id={data?.id}
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="invalid Src">
|
||||
<GradientAvatar
|
||||
className="size-6"
|
||||
className="size-20"
|
||||
src={data ? "invalid-src" : undefined}
|
||||
id={undefined}
|
||||
client={client}
|
||||
|
||||
@@ -14,7 +14,7 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const client = getThirdwebClient();
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 p-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<p> All images below are set with size-6 className </p>
|
||||
|
||||
<BadgeContainer label="No Src - Skeleton">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { DangerSettingCard } from "./DangerSettingCard";
|
||||
|
||||
const meta = {
|
||||
@@ -15,17 +15,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 py-10">
|
||||
|
||||
@@ -9,13 +9,12 @@ import { Img } from "./Img";
|
||||
const meta = {
|
||||
title: "blocks/Img",
|
||||
component: Story,
|
||||
parameters: {},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { MultiNetworkSelector } from "./NetworkSelectors";
|
||||
|
||||
const meta = {
|
||||
@@ -16,20 +16,13 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-8 py-10">
|
||||
<Variant label="No Chains selected by default" selectedChainIds={[]} />
|
||||
<Variant
|
||||
label="Polygon, Ethereum selected by default"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { SettingsCard } from "./SettingsCard";
|
||||
|
||||
const meta = {
|
||||
@@ -15,139 +15,130 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background py-6 text-foreground">
|
||||
<div className="container flex max-w-[1100px] flex-col gap-10 lg:p-10">
|
||||
<BadgeContainer label="No Header">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={undefined}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined}
|
||||
saveButton={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<BadgeContainer label="No Header">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={undefined}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined}
|
||||
saveButton={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Header">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined}
|
||||
saveButton={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
<BadgeContainer label="Header">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined}
|
||||
saveButton={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Error">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
errorText="This is error text"
|
||||
noPermissionText={undefined}
|
||||
saveButton={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
<BadgeContainer label="Error">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
errorText="This is error text"
|
||||
noPermissionText={undefined}
|
||||
saveButton={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Permission">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText="You do not have permission to edit this"
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
<BadgeContainer label="No Permission">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText="You do not have permission to edit this"
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Save">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
<BadgeContainer label="Save">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Save Disabled">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: true,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
<BadgeContainer label="Save Disabled">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: true,
|
||||
isPending: false,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Save Loading">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isPending: true,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
<BadgeContainer label="Save Loading">
|
||||
<SettingsCard
|
||||
bottomText="This is bottom text"
|
||||
header={{
|
||||
title: "Some Title",
|
||||
description: "This is some description for this card",
|
||||
}}
|
||||
saveButton={{
|
||||
disabled: false,
|
||||
isPending: true,
|
||||
onClick: () => {},
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<ChildrenPlaceholder />
|
||||
</SettingsCard>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { mobileViewport } from "../../../stories/utils";
|
||||
import { SidebarLayout } from "./SidebarLayout";
|
||||
|
||||
const meta = {
|
||||
@@ -15,17 +14,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<SidebarLayout
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NavLink } from "../ui/NavLink";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
|
||||
import { CustomSidebar, type SidebarLink } from "./Sidebar";
|
||||
import { AppFooter } from "./app-footer";
|
||||
|
||||
export function SidebarLayout(props: {
|
||||
sidebarLinks: SidebarLink[];
|
||||
@@ -54,7 +55,6 @@ export function FullWidthSidebarLayout(props: {
|
||||
footerSidebarLinks?: SidebarLink[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
const { contentSidebarLinks, children, footerSidebarLinks } = props;
|
||||
return (
|
||||
@@ -65,7 +65,7 @@ export function FullWidthSidebarLayout(props: {
|
||||
)}
|
||||
>
|
||||
{/* left - sidebar */}
|
||||
<Sidebar collapsible="icon" side="left">
|
||||
<Sidebar collapsible="icon" side="left" className="pt-2">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
@@ -95,10 +95,10 @@ export function FullWidthSidebarLayout(props: {
|
||||
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
|
||||
/>
|
||||
|
||||
<main className="container z-0 flex min-w-0 max-w-[1280px] grow flex-col pb-20 max-sm:w-full lg:pt-6">
|
||||
<main className="z-0 flex min-w-0 grow flex-col max-sm:w-full">
|
||||
{children}
|
||||
</main>
|
||||
{props.footer}
|
||||
<AppFooter containerClassName="max-w-7xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
|
||||
const activeLink = useActiveSidebarLink(props.links);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-3 border-b px-4 py-4 lg:hidden">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
|
||||
<SidebarTrigger className="size-4" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
{activeLink && <span className="text-sm">{activeLink.label}</span>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { SingleNetworkSelector } from "./NetworkSelectors";
|
||||
|
||||
const meta = {
|
||||
@@ -16,20 +16,13 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-8 py-10">
|
||||
<Variant label="No Chain ID selected by default" chainId={undefined} />
|
||||
<Variant label="Polygon selected by default" chainId={137} />
|
||||
<Variant
|
||||
|
||||
@@ -12,17 +12,54 @@ import Link from "next/link";
|
||||
|
||||
type AppFooterProps = {
|
||||
className?: string;
|
||||
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
|
||||
className={cn(
|
||||
"w-full border-border border-t bg-card py-6 md:py-8",
|
||||
"w-full border-border border-t py-6 md:py-8",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="container flex flex-col gap-4 md:gap-6">
|
||||
<div
|
||||
className={cn(
|
||||
"container flex flex-col gap-4 md:gap-6",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
{/* top row */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
@@ -74,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
@@ -17,7 +18,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,10 +31,17 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
footer?: React.ReactNode;
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
showLegend?: boolean;
|
||||
maxLimit?: number;
|
||||
yAxis?: boolean;
|
||||
xAxis?: {
|
||||
sameDay?: boolean;
|
||||
};
|
||||
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
@@ -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 ? (
|
||||
@@ -67,17 +77,33 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
props.maxLimit
|
||||
? props.data.map((d) => ({
|
||||
...d,
|
||||
maxLimit: props.maxLimit,
|
||||
}))
|
||||
: 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={
|
||||
@@ -121,6 +147,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
{props.maxLimit && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="maxLimit"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.showLegend && (
|
||||
<ChartLegend
|
||||
@@ -131,6 +167,9 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
)}
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
{props.footer && (
|
||||
<CardFooter className="w-full">{props.footer}</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
@@ -41,7 +42,9 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
chartClassName?: string;
|
||||
isPending: boolean;
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
emptyChartState?: React.ReactElement;
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
@@ -65,12 +68,14 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
{props.customHeader && props.customHeader}
|
||||
|
||||
<CardContent className={cn(!props.header && "pt-6")}>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
<EmptyChartState>{props.emptyChartState}</EmptyChartState>
|
||||
) : (
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
@@ -88,6 +93,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
props.hideLabel !== undefined ? props.hideLabel : true
|
||||
}
|
||||
labelFormatter={props.toolTipLabelFormatter}
|
||||
valueFormatter={props.toolTipValueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -96,25 +102,15 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
content={<ChartLegendContent className="pt-5" />}
|
||||
/>
|
||||
)}
|
||||
{configKeys.map((key, idx) => (
|
||||
{configKeys.map((key) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
// if stacked then they should all be the same stackId
|
||||
// if grouped then they should all be unique stackId (so the key works great)
|
||||
stackId={variant === "stacked" ? "a" : key}
|
||||
fill={`var(--color-${key})`}
|
||||
// if stacked then we need to figure out the radius based on the index in the array
|
||||
// if grouped then we can just use the same radius for all
|
||||
radius={
|
||||
variant === "stacked"
|
||||
? idx === 0
|
||||
? [0, 0, 4, 4]
|
||||
: idx === configKeys.length - 1
|
||||
? [4, 4, 0, 0]
|
||||
: [0, 0, 0, 0]
|
||||
: [4, 4, 4, 4]
|
||||
}
|
||||
fill={props.config[key]?.color}
|
||||
radius={[4, 4, 4, 4]}
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { type CodeEnvironment, CodeSegment } from "./code-segment.client";
|
||||
|
||||
const meta = {
|
||||
@@ -24,17 +24,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
type Mode = "default" | "no-tabs" | "only-tabs";
|
||||
|
||||
function Story() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { MultiSelect } from "./multi-select";
|
||||
|
||||
const meta = {
|
||||
@@ -16,17 +16,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function createList(len: number) {
|
||||
return Array.from({ length: len }, (_, i) => ({
|
||||
value: `${i}`,
|
||||
@@ -36,7 +29,7 @@ function createList(len: number) {
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[600px] flex-col gap-6 px-4 py-6">
|
||||
<div className="container flex max-w-6xl flex-col gap-6 py-10">
|
||||
<VariantTest defaultValues={[]} storyLabel="5 items" listLen={5} />
|
||||
<VariantTest defaultValues={[]} storyLabel="5000 items" listLen={5000} />
|
||||
<VariantTest
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { SelectWithSearch } from "./select-with-search";
|
||||
|
||||
const meta = {
|
||||
@@ -16,17 +16,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function createList(len: number) {
|
||||
return Array.from({ length: len }, (_, i) => ({
|
||||
value: `${i}`,
|
||||
@@ -36,7 +29,7 @@ function createList(len: number) {
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[600px] flex-col gap-6 px-4 py-6">
|
||||
<div className="container flex max-w-6xl flex-col gap-6 py-10">
|
||||
<VariantTest storyLabel="5 items" listLen={5} />
|
||||
<VariantTest storyLabel="5000 items" listLen={5000} />
|
||||
<VariantTest
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../stories/utils";
|
||||
import { BadgeContainer } from "../../stories/utils";
|
||||
import { PaginationButtons } from "./pagination-buttons";
|
||||
|
||||
const meta = {
|
||||
@@ -16,17 +16,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 py-10">
|
||||
|
||||
@@ -6,9 +6,6 @@ import { BadgeContainer } from "../../../stories/utils";
|
||||
const meta = {
|
||||
title: "Shadcn/Buttons",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -243,7 +243,9 @@ const ChartTooltipContent = React.forwardRef<
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
{item.name === "maxLimit"
|
||||
? "Upper Limit"
|
||||
: itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value !== undefined && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { CodeClient } from "./code.client";
|
||||
|
||||
const meta = {
|
||||
@@ -11,17 +11,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const tsCode = `\
|
||||
type User = {
|
||||
name: string;
|
||||
@@ -86,7 +79,7 @@ const bashCode = "pnpm i thirdweb";
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container flex max-w-[600px] flex-col gap-10 py-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<BadgeContainer label="ts">
|
||||
<CodeClient code={tsCode} lang="ts" />
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { PlainTextCodeBlock } from "./plaintext-code";
|
||||
|
||||
const meta = {
|
||||
@@ -11,17 +11,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const jsCode = `\
|
||||
import { getContract } from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
@@ -39,7 +32,7 @@ const overflowText =
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container flex max-w-[600px] flex-col gap-10 py-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<BadgeContainer label="large chunk of code">
|
||||
<PlainTextCodeBlock code={jsCode} />
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -13,9 +13,6 @@ import { BadgeContainer } from "../../../stories/utils";
|
||||
const meta = {
|
||||
title: "Shadcn/Select",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -10,35 +10,25 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import Link from "next/link";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
const meta = {
|
||||
title: "Shadcn/Table",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 px-4 py-6 lg:mx-auto lg:max-w-[1000px]">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<BadgeContainer label="Normal">
|
||||
<TableDemo />
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -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;
|
||||
@@ -20,6 +21,7 @@ export function TabLinks(props: {
|
||||
className?: string;
|
||||
tabContainerClassName?: string;
|
||||
shadowColor?: string;
|
||||
scrollableClassName?: string;
|
||||
}) {
|
||||
const { containerRef, lineRef, activeTabRef } =
|
||||
useUnderline<HTMLAnchorElement>();
|
||||
@@ -30,7 +32,7 @@ export function TabLinks(props: {
|
||||
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
|
||||
|
||||
<ScrollShadow
|
||||
scrollableClassName="pb-[8px] relative"
|
||||
scrollableClassName={cn("pb-[8px] relative", props.scrollableClassName)}
|
||||
shadowColor={props.shadowColor}
|
||||
>
|
||||
<div
|
||||
@@ -81,6 +83,7 @@ export function TabButtons(props: {
|
||||
isActive: boolean;
|
||||
isDisabled?: boolean;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
toolTip?: string;
|
||||
}[];
|
||||
tabClassName?: string;
|
||||
activeTabClassName?: string;
|
||||
@@ -110,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>
|
||||
@@ -159,9 +167,11 @@ function useUnderline<El extends HTMLElement>() {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const lineEl = lineRef.current;
|
||||
const rect = activeTabEl.getBoundingClientRect();
|
||||
const containerPaddingLeft =
|
||||
containerRect.left - containerRef.current.offsetLeft;
|
||||
lineEl.style.width = `${rect.width}px`;
|
||||
lineEl.style.transform = `translateX(${
|
||||
rect.left - containerRect.left
|
||||
rect.left - containerPaddingLeft
|
||||
}px)`;
|
||||
setTimeout(() => {
|
||||
lineEl.style.transition = "transform 0.3s, width 0.3s";
|
||||
@@ -205,6 +215,7 @@ export function TabPathLinks(props: {
|
||||
className?: string;
|
||||
tabContainerClassName?: string;
|
||||
shadowColor?: string;
|
||||
scrollableClassName?: string;
|
||||
}) {
|
||||
const pathname = usePathname() || "";
|
||||
const { links, ...restProps } = props;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export const SearchInput: React.FC = () => {
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by token address or chain ID"
|
||||
placeholder="Search with anything!"
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
|
||||
+72
-8
@@ -7,6 +7,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Address } from "thirdweb";
|
||||
import { checksumAddress } from "thirdweb/utils";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "../server/routelist-card";
|
||||
@@ -34,28 +35,83 @@ async function getRoutesToRender(params: SearchParams) {
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
originTextQuery?: string;
|
||||
destinationTextQuery?: string;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.originTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.originChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.originTextQuery = params.query;
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.destinationTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.destinationTextQuery = params.query;
|
||||
}
|
||||
}
|
||||
// Temporary, will update this after the /routes endpoint
|
||||
filters.limit = 50_000;
|
||||
|
||||
const routes = await getRoutes(filters);
|
||||
let routes = await getRoutes({ limit: 100_000 });
|
||||
|
||||
const totalCount = routes.length;
|
||||
|
||||
if (filters.originChainId) {
|
||||
routes = routes.filter(
|
||||
(route) => route.originToken.chainId === filters.originChainId,
|
||||
);
|
||||
}
|
||||
if (filters.originTokenAddress) {
|
||||
const originTokenAddress = filters.originTokenAddress;
|
||||
routes = routes.filter(
|
||||
(route) =>
|
||||
checksumAddress(route.originToken.address) ===
|
||||
checksumAddress(originTokenAddress),
|
||||
);
|
||||
}
|
||||
if (filters.destinationChainId) {
|
||||
routes = routes.filter(
|
||||
(route) => route.destinationToken.chainId === filters.destinationChainId,
|
||||
);
|
||||
}
|
||||
if (filters.destinationTokenAddress) {
|
||||
const destinationTokenAddress = filters.destinationTokenAddress;
|
||||
routes = routes.filter(
|
||||
(route) =>
|
||||
checksumAddress(route.destinationToken.address) ===
|
||||
checksumAddress(destinationTokenAddress),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.originTextQuery) {
|
||||
const originTextQuery = filters.originTextQuery.toLowerCase();
|
||||
routes = routes.filter((route) => {
|
||||
return (
|
||||
route.originToken.name.toLowerCase().includes(originTextQuery) ||
|
||||
route.originToken.symbol.toLowerCase().includes(originTextQuery)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.destinationTextQuery) {
|
||||
const destinationTextQuery = filters.destinationTextQuery.toLowerCase();
|
||||
routes = routes.filter((route) => {
|
||||
return (
|
||||
route.destinationToken.name
|
||||
.toLowerCase()
|
||||
.includes(destinationTextQuery) ||
|
||||
route.destinationToken.symbol
|
||||
.toLowerCase()
|
||||
.includes(destinationTextQuery)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
routesToRender: routes,
|
||||
totalCount,
|
||||
@@ -154,13 +210,21 @@ export async function RoutesData(props: {
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">{filteredCount}</span>{" "}
|
||||
<span className="text-accent-foreground">
|
||||
{filteredCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">{totalCount}</span>)
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">{totalCount}</span> routes.
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -2,43 +2,18 @@ import "server-only";
|
||||
|
||||
import { BRIDGE_URL, DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/env";
|
||||
import { getAuthToken } from "app/api/lib/getAuthToken";
|
||||
import type { Address } from "thirdweb";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${BRIDGE_URL}/v1/routes`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
|
||||
const token = await getAuthToken();
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: token
|
||||
|
||||
+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]);
|
||||
|
||||
+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>
|
||||
);
|
||||
|
||||
+32
-46
@@ -3,14 +3,11 @@ import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { toast } from "sonner";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import {
|
||||
ErrorProvider,
|
||||
type TransactionError,
|
||||
} from "../../../../../../../contexts/error-handler";
|
||||
import type { TransactionError } from "../../../../../../../contexts/error-handler";
|
||||
import { accountStub } from "../../../../../../../stories/stubs";
|
||||
import {
|
||||
BatchMetadataModuleUI,
|
||||
@@ -21,7 +18,6 @@ const meta = {
|
||||
title: "Modules/BatchMetadata",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
@@ -31,17 +27,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
|
||||
@@ -80,38 +69,35 @@ function Component() {
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<ErrorProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div>
|
||||
<ConnectButton client={getThirdwebClient()} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<CheckboxWithLabel
|
||||
value={isOwner}
|
||||
onChange={setIsOwner}
|
||||
id="isOwner"
|
||||
label="Is Owner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="Default">
|
||||
<BatchMetadataModuleUI
|
||||
twAccount={accountStub()}
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
uploadMetadata={uploadMetadataStub}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
<Toaster richColors />
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<div>
|
||||
<ConnectButton client={getThirdwebClient()} />
|
||||
</div>
|
||||
</ErrorProvider>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<CheckboxWithLabel
|
||||
value={isOwner}
|
||||
onChange={setIsOwner}
|
||||
id="isOwner"
|
||||
label="Is Owner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="Default">
|
||||
<BatchMetadataModuleUI
|
||||
twAccount={accountStub()}
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
uploadMetadata={uploadMetadataStub}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-15
@@ -11,8 +11,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { subDays } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { mobileViewport } from "stories/utils";
|
||||
import { toast } from "sonner";
|
||||
import { NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { checksumAddress } from "thirdweb/utils";
|
||||
@@ -29,7 +28,6 @@ const meta = {
|
||||
title: "Modules/Claimable",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
@@ -39,17 +37,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
const claimCondition = {
|
||||
@@ -67,7 +58,7 @@ const claimCondition = {
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
const [name, setName] = useState("ClaimableERC721");
|
||||
const [name, setName] = useState("MintableERC721");
|
||||
const [isClaimConditionLoading, setIsClaimConditionLoading] = useState(false);
|
||||
const [isPrimarySaleRecipientLoading, setIsPrimarySaleRecipientLoading] =
|
||||
useState(false);
|
||||
@@ -110,7 +101,7 @@ function Component() {
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<div>
|
||||
<ConnectButton client={getThirdwebClient()} />
|
||||
</div>
|
||||
@@ -196,8 +187,6 @@ function Component() {
|
||||
isValidTokenId={true}
|
||||
noClaimConditionSet={noClaimConditionSet}
|
||||
/>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
+4
-14
@@ -11,8 +11,8 @@ import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { toast } from "sonner";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { accountStub } from "../../../../../../../stories/stubs";
|
||||
import {
|
||||
@@ -25,7 +25,6 @@ const meta = {
|
||||
title: "Modules/Mintable",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
@@ -35,17 +34,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
function Component() {
|
||||
@@ -84,7 +76,7 @@ function Component() {
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<div>
|
||||
<ConnectButton client={getThirdwebClient()} />
|
||||
</div>
|
||||
@@ -175,8 +167,6 @@ function Component() {
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
+2
-15
@@ -2,33 +2,22 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { ModuleCardUI } from "./module-card";
|
||||
|
||||
const meta = {
|
||||
title: "Modules/ModuleCard",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
|
||||
@@ -120,8 +109,6 @@ function Component() {
|
||||
</div>
|
||||
</ModuleCardUI>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
+3
-13
@@ -3,8 +3,8 @@ import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { toast } from "sonner";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { accountStub } from "../../../../../../../stories/stubs";
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ const meta = {
|
||||
title: "Modules/OpenEditionMetadata",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
@@ -26,17 +25,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
async function setSharedMetadataStub(values: SetSharedMetadataFormValues) {
|
||||
@@ -98,8 +90,6 @@ function Component() {
|
||||
twAccount={accountStub()}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
+4
-14
@@ -3,8 +3,8 @@ import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { toast } from "sonner";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { accountStub } from "../../../../../../../stories/stubs";
|
||||
import {
|
||||
@@ -18,7 +18,6 @@ const meta = {
|
||||
title: "Modules/Royalty",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
@@ -28,17 +27,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const _testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
function Component() {
|
||||
@@ -79,7 +71,7 @@ function Component() {
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<div>
|
||||
<ConnectButton client={getThirdwebClient()} />
|
||||
</div>
|
||||
@@ -168,8 +160,6 @@ function Component() {
|
||||
twAccount={twAccount}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
+4
-14
@@ -3,8 +3,8 @@ import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { toast } from "sonner";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { accountStub } from "../../../../../../../stories/stubs";
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ const meta = {
|
||||
title: "Modules/Transferable",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
@@ -26,17 +25,10 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
function Component() {
|
||||
@@ -65,7 +57,7 @@ function Component() {
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<div className="flex gap-2">
|
||||
<Checkbox
|
||||
id="terms1"
|
||||
@@ -139,8 +131,6 @@ function Component() {
|
||||
twAccount={accountStub()}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
+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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user