Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be49798e99 | ||
|
|
167f9213ff | ||
|
|
bb09e20bcb | ||
|
|
78b9c8e334 | ||
|
|
a5163c0c7f | ||
|
|
55baa997f0 | ||
|
|
c75e434498 | ||
|
|
2b5941ff7d | ||
|
|
85b21ec65c | ||
|
|
508b4c48e5 | ||
|
|
a5e086f173 | ||
|
|
8af6143d53 | ||
|
|
ed81006741 | ||
|
|
3f74760113 | ||
|
|
9253c54cf9 | ||
|
|
913ea98d23 | ||
|
|
5ce8a5618f | ||
|
|
35a909a897 | ||
|
|
9f6c1a4820 | ||
|
|
e43afa1b4e | ||
|
|
94e2cbc468 | ||
|
|
64ee04c25d | ||
|
|
680b8cdb16 | ||
|
|
787db9308d | ||
|
|
c152db3e2f | ||
|
|
5f57300cf9 | ||
|
|
db20bd17b1 | ||
|
|
0a85205e4a | ||
|
|
cda390eefe | ||
|
|
305f738ff1 | ||
|
|
a1c875c596 | ||
|
|
38f1821eb9 | ||
|
|
848984481f | ||
|
|
950bbaee1c | ||
|
|
99d6b3b151 | ||
|
|
81c66f4e7b | ||
|
|
639356071c | ||
|
|
62af256575 | ||
|
|
eb1c705654 | ||
|
|
9643b8d2c5 | ||
|
|
56d27d4c63 | ||
|
|
e742929b2c | ||
|
|
dacfd94af5 | ||
|
|
2d15cd6a47 | ||
|
|
62a3cd8348 | ||
|
|
2a4195b4ad | ||
|
|
6378f371c5 | ||
|
|
b3e42f7407 | ||
|
|
044f897763 | ||
|
|
7fa5be5317 | ||
|
|
2510c4f0c1 | ||
|
|
69bf925c01 | ||
|
|
d4aed43b18 | ||
|
|
f89c15c517 | ||
|
|
c95c0dc5bd | ||
|
|
c77b4d8f85 | ||
|
|
5c4c49b90a | ||
|
|
a5cd66439c | ||
|
|
05f65ec150 | ||
|
|
6610633799 | ||
|
|
7ab79dc3bb | ||
|
|
2b39669e46 | ||
|
|
e3e3142f7e | ||
|
|
7c0b80d132 | ||
|
|
5c1db8bf27 | ||
|
|
a87446a9e6 | ||
|
|
9493fda7bf | ||
|
|
59dbae0c21 | ||
|
|
42236d43a8 | ||
|
|
a920d1ee07 | ||
|
|
2329a22bf1 | ||
|
|
b25d2afa8b | ||
|
|
8910f55ca2 | ||
|
|
b237318b7d | ||
|
|
082d9630f4 | ||
|
|
d4bc8e199d | ||
|
|
4edf1ebe95 | ||
|
|
b12725b231 | ||
|
|
3d1c321bd6 | ||
|
|
810ec6efa7 | ||
|
|
1c6ed9bd0d | ||
|
|
82035fd0b7 | ||
|
|
8b324597fb | ||
|
|
5ebb7549ca | ||
|
|
dfe4cf0928 | ||
|
|
6cce31d828 | ||
|
|
aaad876a6f | ||
|
|
de297e4c97 | ||
|
|
2114e5f91f | ||
|
|
ca0841745a | ||
|
|
d150ea1c91 | ||
|
|
f1479b85f0 | ||
|
|
283dc289fc |
@@ -61,7 +61,7 @@ jobs:
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@f382a98e582959e6aaac8e5f8b17b31749018780 # v2.5.0
|
||||
uses: biomejs/setup-biome@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
|
||||
|
||||
@@ -20,6 +20,7 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
|
||||
- Biome governs formatting and linting; its rules live in biome.json.
|
||||
- Run pnpm biome check --apply before committing.
|
||||
- Avoid editor‑specific configs; rely on the shared settings.
|
||||
- make sure everything builds after each file change by running `pnpm build`
|
||||
|
||||
⸻
|
||||
|
||||
@@ -39,7 +40,8 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
|
||||
- Co‑locate tests: foo.ts ↔ foo.test.ts.
|
||||
- Use real function invocations with stub data; avoid brittle mocks.
|
||||
- For network interactions, use Mock Service Worker (MSW) to intercept fetch/HTTP calls, mocking only scenarios that are hard to reproduce.
|
||||
- Keep tests deterministic and side‑effect free; Jest is pre‑configured.
|
||||
- Keep tests deterministic and side‑effect free; Vitest is pre‑configured.
|
||||
- to run the tests: `cd packages thirdweb & pnpm test:dev <filename>`
|
||||
|
||||
⸻
|
||||
|
||||
|
||||
@@ -44,9 +44,6 @@ NEXT_PUBLIC_STRIPE_KEY=
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID=
|
||||
|
||||
# Needed for contract analytics / blockchain data information
|
||||
CHAINSAW_API_KEY=
|
||||
|
||||
#
|
||||
# Private (server)
|
||||
#
|
||||
@@ -65,17 +62,11 @@ MORALIS_API_KEY=
|
||||
# - not required to build (unless testing wallet NFTs)>
|
||||
SSR_ALCHEMY_KEY=
|
||||
|
||||
# beehiiv.com API key (used for newsletter signups)
|
||||
# - not required to build (unless testing newsletter signups)>
|
||||
BEEHIIV_API_KEY=
|
||||
|
||||
# Hubspot Access Token (used for contact us form)
|
||||
# - not required to build (unless testing contact us form)>
|
||||
HUBSPOT_ACCESS_TOKEN=
|
||||
|
||||
# Github API Token (used for /open-source)
|
||||
GITHUB_API_TOKEN="ghp_..."
|
||||
|
||||
# Upload server url
|
||||
NEXT_PUBLIC_DASHBOARD_UPLOAD_SERVER="https://storage.thirdweb-preview.com"
|
||||
|
||||
@@ -100,8 +91,6 @@ REDIS_URL=""
|
||||
|
||||
ANALYTICS_SERVICE_URL=""
|
||||
|
||||
# Required for Nebula Chat
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
|
||||
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
|
||||
STRIPE_SECRET_KEY=""
|
||||
|
||||
@@ -13,9 +13,8 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@chromatic-com/storybook"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-docs"),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/nextjs"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import type { Preview } from "@storybook/nextjs";
|
||||
import "../src/global.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
|
||||
@@ -6,13 +6,14 @@ module.exports = [
|
||||
"/bounties",
|
||||
"/contact-us",
|
||||
// -- product landing pages --
|
||||
// -- connect
|
||||
"/connect",
|
||||
"/connect/sign-in",
|
||||
"/connect/account-abstraction",
|
||||
"/connect/universal-bridge",
|
||||
"/connect/auth",
|
||||
"/connect/in-app-wallets",
|
||||
// -- build category
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/universal-bridge",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
// -- end build category
|
||||
// -- storage
|
||||
"/storage",
|
||||
// -- nebula
|
||||
@@ -24,8 +25,7 @@ module.exports = [
|
||||
"/contracts/modular-contracts",
|
||||
"/contracts/explore",
|
||||
"/contracts/deployment-tool",
|
||||
// -- engine
|
||||
"/engine",
|
||||
|
||||
// -- solutions pages --
|
||||
"/solutions/:solution_slug",
|
||||
// -- campaigns --
|
||||
@@ -40,7 +40,8 @@ module.exports = [
|
||||
"/community/ambassadors",
|
||||
"/community/startup-program",
|
||||
// -- grants --
|
||||
"/grant/superchain",
|
||||
"/grants",
|
||||
"/superchain",
|
||||
// -- templates --
|
||||
"/templates",
|
||||
"/templates/:template_slug",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"project": ["src/**"],
|
||||
"ignoreBinaries": ["only-allow", "biome"],
|
||||
"ignoreDependencies": [
|
||||
"@storybook/blocks",
|
||||
"@thirdweb-dev/service-utils",
|
||||
"@thirdweb-dev/vault-sdk",
|
||||
"@types/color",
|
||||
|
||||
@@ -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;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com googletagmanager.com us-assets.i.posthog.com edit.framer.com;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
|
||||
+43
-49
@@ -29,30 +29,30 @@
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.7",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.10",
|
||||
"@radix-ui/react-avatar": "^1.1.6",
|
||||
"@radix-ui/react-checkbox": "^1.2.2",
|
||||
"@radix-ui/react-dialog": "1.1.10",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.11",
|
||||
"@radix-ui/react-hover-card": "^1.1.10",
|
||||
"@radix-ui/react-label": "^2.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.10",
|
||||
"@radix-ui/react-progress": "^1.1.4",
|
||||
"@radix-ui/react-radio-group": "^1.3.3",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "1.2.3",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@scalar/api-reference-react": "^0.6.19",
|
||||
"@sentry/nextjs": "9.13.0",
|
||||
"@shazow/whatsabi": "0.21.0",
|
||||
"@tanstack/react-query": "5.74.4",
|
||||
"@sentry/nextjs": "9.29.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.0.0",
|
||||
"@vercel/functions": "2.1.0",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"abitype": "1.0.8",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
@@ -61,17 +61,15 @@
|
||||
"color": "5.0.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.1",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "12.9.2",
|
||||
"framer-motion": "12.17.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.503.0",
|
||||
"next": "15.3.2",
|
||||
"lucide-react": "0.514.0",
|
||||
"next": "15.3.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
@@ -96,54 +94,50 @@
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "2.0.3",
|
||||
"sonner": "2.0.5",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.21.0",
|
||||
"swagger-ui-react": "^5.24.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "3.25.24"
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.62"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@next/bundle-analyzer": "15.3.2",
|
||||
"@next/eslint-plugin-next": "15.3.2",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/addon-onboarding": "8.6.14",
|
||||
"@storybook/addon-viewport": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/nextjs": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@chromatic-com/storybook": "4.0.0",
|
||||
"@next/bundle-analyzer": "15.3.3",
|
||||
"@next/eslint-plugin-next": "15.3.3",
|
||||
"@playwright/test": "1.53.0",
|
||||
"@storybook/addon-docs": "9.0.8",
|
||||
"@storybook/addon-links": "9.0.8",
|
||||
"@storybook/addon-onboarding": "9.0.8",
|
||||
"@storybook/nextjs": "9.0.8",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"checkly": "5.2.0",
|
||||
"checkly": "5.6.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"knip": "5.56.0",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.8",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.3",
|
||||
"storybook": "8.6.14",
|
||||
"postcss": "8.5.5",
|
||||
"storybook": "9.0.8",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
|
||||
+40
-26
@@ -213,7 +213,7 @@ async function redirects() {
|
||||
{
|
||||
source: "/cli/login",
|
||||
destination:
|
||||
"https://support.thirdweb.com/troubleshooting-errors/7Y1BqKNvtLdBv5fZkRZZB3/issue-linking-device-on-the-authorization-page-via-thirdweb-cli/cn9LRA3ax7XCP6uxwRYdvx",
|
||||
"https://portal.thirdweb.com/knowledge-base/onchain-common-errors/thirdweb-cli/device-link-error",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
@@ -238,11 +238,6 @@ async function redirects() {
|
||||
destination: "/contracts/explore",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/build",
|
||||
destination: "/contracts/modular-contracts",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/ui-components",
|
||||
destination: "/sdk",
|
||||
@@ -319,28 +314,10 @@ async function redirects() {
|
||||
destination: "/templates/:slug",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /account-abstraction to /connect/account-abstraction
|
||||
{
|
||||
source: "/account-abstraction",
|
||||
destination: "/connect/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /connect/pay to /connect/universal-bridge
|
||||
// redirect /connect/pay to /universal-bridge
|
||||
{
|
||||
source: "/connect/pay",
|
||||
destination: "/connect/universal-bridge",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /auth to /connect/auth
|
||||
{
|
||||
source: "/auth",
|
||||
destination: "/connect/auth",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /in-app-wallets to /connect/in-app-wallets
|
||||
{
|
||||
source: "/in-app-wallets",
|
||||
destination: "/connect/in-app-wallets",
|
||||
destination: "/universal-bridge",
|
||||
permanent: false,
|
||||
},
|
||||
// PREVIOUS CAMPAIGNS
|
||||
@@ -368,6 +345,43 @@ async function redirects() {
|
||||
destination: "/learn/guides",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect to /grant/superchain to /superchain
|
||||
{
|
||||
source: "/grant/superchain",
|
||||
destination: "/superchain",
|
||||
permanent: false,
|
||||
},
|
||||
// connect -> build redirects
|
||||
{
|
||||
source: "/connect",
|
||||
destination: "/wallets",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/connect/account-abstraction",
|
||||
destination: "/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/connect/universal-bridge",
|
||||
destination: "/universal-bridge",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/connect/auth",
|
||||
destination: "/auth",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/connect/in-app-wallets",
|
||||
destination: "/in-app-wallets",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/engine",
|
||||
destination: "/transactions",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
...legacyDashboardToTeamRedirects,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
// biome-ignore lint/style/useNodejsImportProtocol: breaks storybook if it's `node:` prefixed
|
||||
import { randomBytes } from "crypto";
|
||||
import type { Team } from "@/api/team";
|
||||
import { format } from "date-fns";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
|
||||
export async function createTeam(options?: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name:
|
||||
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
|
||||
slug: options?.slug ?? randomBytes(20).toString("hex"),
|
||||
billingEmail: null,
|
||||
image: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
console.error("failed to create team", {
|
||||
status: res.status,
|
||||
reason,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 400: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "Invalid team name or slug.",
|
||||
} as const;
|
||||
}
|
||||
case 401: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action.",
|
||||
} as const;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "An unknown error occurred.",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
result: Team;
|
||||
};
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: json.result,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
|
||||
export async function deleteTeam(options: {
|
||||
teamId: string;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action.",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
// handle errors
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
console.error("failed to delete team", {
|
||||
status: res.status,
|
||||
reason,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 400: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "Invalid team ID.",
|
||||
} as const;
|
||||
}
|
||||
case 401: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action.",
|
||||
} as const;
|
||||
}
|
||||
|
||||
case 403: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You do not have permission to delete this team.",
|
||||
} as const;
|
||||
}
|
||||
case 404: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "Team not found.",
|
||||
} as const;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "An unknown error occurred.",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
@@ -59,3 +59,16 @@ export async function getTeamInvoices(
|
||||
throw new Error("Failed to fetch billing history");
|
||||
}
|
||||
}
|
||||
|
||||
async function getStripeCustomer(customerId: string) {
|
||||
return await getStripe().customers.retrieve(customerId);
|
||||
}
|
||||
|
||||
export async function getStripeBalance(customerId: string) {
|
||||
const customer = await getStripeCustomer(customerId);
|
||||
if (customer.deleted) {
|
||||
return 0;
|
||||
}
|
||||
// Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0)
|
||||
return customer.balance === 0 ? 0 : customer.balance / -100;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ async function fetchAnalytics(
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("You are not authorized to perform this action");
|
||||
}
|
||||
@@ -48,22 +47,11 @@ async function fetchAnalytics(
|
||||
decodeURIComponent(value),
|
||||
);
|
||||
}
|
||||
// client id DEBUG OVERRIDE
|
||||
// analyticsServiceUrl.searchParams.delete("projectId");
|
||||
// analyticsServiceUrl.searchParams.delete("teamId");
|
||||
// analyticsServiceUrl.searchParams.append(
|
||||
// "teamId",
|
||||
// "team_cm0lde33r02344w129k5hm2xz",
|
||||
// );
|
||||
// analyticsServiceUrl.searchParams.append(
|
||||
// "projectId",
|
||||
// "prj_cm4rqwx9b002qrnsnr37wqpo6",
|
||||
// );
|
||||
|
||||
return fetch(analyticsServiceUrl, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
@@ -99,7 +87,6 @@ export async function getWalletConnections(
|
||||
`v2/sdk/wallet-connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -123,7 +110,6 @@ export async function getInAppWalletUsage(
|
||||
`v2/wallet/connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -147,7 +133,6 @@ export async function getUserOpUsage(
|
||||
`v2/bundler/usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -205,7 +190,6 @@ export async function getClientTransactions(
|
||||
`v2/sdk/contract-transactions?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -229,7 +213,6 @@ export async function getRpcMethodUsage(
|
||||
`v2/rpc/evm-methods?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -250,7 +233,6 @@ export async function getWalletUsers(
|
||||
`v2/sdk/wallet-connects/users?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -287,7 +269,6 @@ export async function isProjectActive(params: {
|
||||
`v2/active-usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -357,9 +338,6 @@ export async function getEcosystemWalletUsage(args: {
|
||||
`v2/wallet/connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -386,9 +364,6 @@ export async function getUniversalBridgeUsage(args: {
|
||||
const searchParams = buildSearchParams(args);
|
||||
const res = await fetchAnalytics(`v2/universal?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (res?.status !== 200) {
|
||||
@@ -415,9 +390,6 @@ export async function getUniversalBridgeWalletUsage(args: {
|
||||
`v2/universal/wallets?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -441,7 +413,6 @@ export async function getEngineCloudMethodUsage(
|
||||
`v2/engine-cloud/requests?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
|
||||
export type AuditLogEntry = {
|
||||
who: {
|
||||
text: string;
|
||||
metadata?: {
|
||||
email?: string;
|
||||
image?: string;
|
||||
wallet?: string;
|
||||
clientId?: string;
|
||||
};
|
||||
type: "user" | "apikey" | "system";
|
||||
path?: string;
|
||||
};
|
||||
what: {
|
||||
text: string;
|
||||
action: "create" | "update" | "delete";
|
||||
path?: string;
|
||||
in?: {
|
||||
text: string;
|
||||
path?: string;
|
||||
};
|
||||
description?: string;
|
||||
resourceType:
|
||||
| "team"
|
||||
| "project"
|
||||
| "team-member"
|
||||
| "team-invite"
|
||||
| "contract"
|
||||
| "secret-key";
|
||||
};
|
||||
when: string;
|
||||
};
|
||||
|
||||
type AuditLogApiResponse = {
|
||||
result: AuditLogEntry[];
|
||||
nextCursor?: string;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export async function getAuditLogs(teamSlug: string, cursor?: string) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("No auth token found");
|
||||
}
|
||||
const url = new URL(
|
||||
`/v1/teams/${teamSlug}/audit-log`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
);
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
// artifically limit page size to 15 for now
|
||||
url.searchParams.set("take", "15");
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: {
|
||||
// revalidate this query once per 10 seconds (does not need to be more granular than that)
|
||||
revalidate: 10,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// if the status is 402, the most likely reason is that the team is on a free plan
|
||||
if (response.status === 402) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "higher_plan_required",
|
||||
} as const;
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AuditLogApiResponse;
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
accountId: string;
|
||||
teamId: string | null;
|
||||
description: string;
|
||||
readAt: string | null;
|
||||
ctaText: string;
|
||||
ctaUrl: string;
|
||||
};
|
||||
|
||||
export type NotificationsApiResponse = {
|
||||
result: Notification[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
export async function getUnreadNotifications(cursor?: string) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("No auth token found");
|
||||
}
|
||||
const url = new URL(
|
||||
"/v1/dashboard-notifications/unread",
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
);
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as NotificationsApiResponse;
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function getArchivedNotifications(cursor?: string) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("No auth token found");
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
"/v1/dashboard-notifications/archived",
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
);
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as NotificationsApiResponse;
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function getUnreadNotificationsCount() {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("No auth token found");
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
"/v1/dashboard-notifications/unread-count",
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
} as const;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
result: {
|
||||
unreadCount: number;
|
||||
};
|
||||
};
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(notificationId?: string) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("No auth token found");
|
||||
}
|
||||
const url = new URL(
|
||||
"/v1/dashboard-notifications/mark-as-read",
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
// if notificationId is provided, mark it as read, otherwise mark all as read
|
||||
body: JSON.stringify(notificationId ? { notificationId } : {}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export async function getMembers(teamSlug: string) {
|
||||
}
|
||||
|
||||
const teamsRes = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamSlug}/members`,
|
||||
new URL(`/v1/teams/${teamSlug}/members`, NEXT_PUBLIC_THIRDWEB_API_HOST),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -48,7 +48,10 @@ export async function getMembers(teamSlug: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getMemberById(teamSlug: string, memberId: string) {
|
||||
export async function getMemberByAccountId(
|
||||
teamSlug: string,
|
||||
accountId: string,
|
||||
) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
@@ -56,7 +59,10 @@ export async function getMemberById(teamSlug: string, memberId: string) {
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamSlug}/members/${memberId}`,
|
||||
new URL(
|
||||
`/v1/teams/${teamSlug}/members/${accountId}`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
||||
@@ -3,8 +3,10 @@ import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import type { TeamResponse } from "@thirdweb-dev/service-utils";
|
||||
import { cookies } from "next/headers";
|
||||
import { getValidAccount } from "../../app/(app)/account/settings/getAccount";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { LAST_USED_TEAM_ID } from "../../constants/cookies";
|
||||
import { getMemberByAccountId } from "./team-members";
|
||||
|
||||
export type Team = TeamResponse & { stripeCustomerId: string | null };
|
||||
|
||||
@@ -67,6 +69,7 @@ export async function getTeams() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export async function getDefaultTeam() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
@@ -102,3 +105,23 @@ export async function getLastVisitedTeam() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function hasToCompleteTeamOnboarding(
|
||||
team: Team,
|
||||
pagePath: string,
|
||||
) {
|
||||
// if the team is already onboarded, we don't need to check anything else here
|
||||
if (team.isOnboarded) {
|
||||
return false;
|
||||
}
|
||||
const account = await getValidAccount(pagePath);
|
||||
const teamMember = await getMemberByAccountId(team.slug, account.id);
|
||||
|
||||
// if the team member is not an owner (or we cannot find them), they cannot complete onboarding anyways
|
||||
if (teamMember?.role !== "OWNER") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if we get here the team is not onboarded and the team member is an owner, so we need to show the onboarding page
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -47,13 +47,7 @@ export async function createWebhook(props: {
|
||||
secret?: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
console.log(
|
||||
"UB_BASE_URL",
|
||||
UB_BASE_URL,
|
||||
props.clientId,
|
||||
props.teamId,
|
||||
authToken,
|
||||
);
|
||||
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "../../../app/(app)/api/lib/getAuthToken";
|
||||
|
||||
type LineItem = {
|
||||
quantity: number;
|
||||
amountUsdCents: number;
|
||||
unitAmountUsdCents: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type UsageCategory = {
|
||||
category: string;
|
||||
unitName: string;
|
||||
lineItems: LineItem[];
|
||||
};
|
||||
|
||||
type UsageApiResponse = {
|
||||
result: UsageCategory[];
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
planVersion: number;
|
||||
};
|
||||
|
||||
export async function getBilledUsage(teamSlug: string) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("No auth token found");
|
||||
}
|
||||
const response = await fetch(
|
||||
new URL(
|
||||
`/v1/teams/${teamSlug}/billing/billed-usage`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
next: {
|
||||
// revalidate this query once per minute (does not need to be more granular than that)
|
||||
revalidate: 60,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
// if the status is 404, the most likely reason is that the team is on a free plan
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "free_plan",
|
||||
} as const;
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
} as const;
|
||||
}
|
||||
const data = (await response.json()) as UsageApiResponse;
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
} as const;
|
||||
}
|
||||
@@ -2,58 +2,6 @@ import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { ANALYTICS_SERVICE_URL } from "../../constants/server-envs";
|
||||
|
||||
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 = ANALYTICS_SERVICE_URL;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Suspense } from "react";
|
||||
import { useIsMobile } from "../hooks/use-mobile";
|
||||
|
||||
export function ResponsiveLayout(props: {
|
||||
desktop: React.ReactNode;
|
||||
mobile: React.ReactNode;
|
||||
fallback: React.ReactNode;
|
||||
debugMode?: boolean;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (props.debugMode) {
|
||||
return props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={props.fallback}>
|
||||
<ClientOnly ssr={props.fallback}>
|
||||
{isMobile ? props.mobile : props.desktop}
|
||||
</ClientOnly>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -84,12 +84,13 @@ export function BillingPortalButton(props: {
|
||||
props.buttonProps?.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
<a
|
||||
href={buildBillingPortalUrl({ teamSlug: props.teamSlug })}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { DangerSettingCard } from "./DangerSettingCard";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
|
||||
@@ -11,6 +11,7 @@ type imgElementProps = React.DetailedHTMLProps<
|
||||
skeleton?: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
src: string | undefined;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export function Img(props: imgElementProps) {
|
||||
@@ -23,7 +24,8 @@ export function Img(props: imgElementProps) {
|
||||
: props.src === ""
|
||||
? "fallback"
|
||||
: _status;
|
||||
const { className, fallback, skeleton, ...restProps } = props;
|
||||
const { className, fallback, skeleton, containerClassName, ...restProps } =
|
||||
props;
|
||||
const defaultSkeleton = <div className="animate-pulse bg-accent" />;
|
||||
const defaultFallback = <div className="bg-accent" />;
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
@@ -47,7 +49,7 @@ export function Img(props: imgElementProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<div className={cn("relative shrink-0", containerClassName)}>
|
||||
<img
|
||||
{...restProps}
|
||||
// avoid setting empty src string to prevent request to the entire page
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { SettingsCard } from "./SettingsCard";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { SidebarLayout } from "./SidebarLayout";
|
||||
|
||||
const meta = {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { MultiSelect } from "./multi-select";
|
||||
|
||||
interface SignatureOption {
|
||||
label: string;
|
||||
value: string;
|
||||
abi?: string;
|
||||
}
|
||||
|
||||
interface SignatureSelectorProps {
|
||||
options: SignatureOption[];
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
setAbi?: (abi: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
secondaryTextFormatter?: (sig: SignatureOption) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SignatureSelector({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
setAbi,
|
||||
placeholder = "Select or enter a signature",
|
||||
disabled,
|
||||
secondaryTextFormatter,
|
||||
className,
|
||||
}: SignatureSelectorProps) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Memoize options with formatted secondary text if provided
|
||||
const formattedOptions = useMemo(() => {
|
||||
return options.map((opt) => ({
|
||||
...opt,
|
||||
label: secondaryTextFormatter
|
||||
? `${opt.label} • ${secondaryTextFormatter(opt)}`
|
||||
: opt.label,
|
||||
}));
|
||||
}, [options, secondaryTextFormatter]);
|
||||
|
||||
// Check if the current value is a custom value (not in options)
|
||||
const isCustomValue = value && !options.some((opt) => opt.value === value);
|
||||
|
||||
// Add the custom value as an option if needed
|
||||
const allOptions = useMemo(() => {
|
||||
if (isCustomValue && value) {
|
||||
return [...formattedOptions, { label: value, value }];
|
||||
}
|
||||
return formattedOptions;
|
||||
}, [formattedOptions, isCustomValue, value]);
|
||||
|
||||
// Single-select MultiSelect wrapper
|
||||
const handleSelectedValuesChange = useCallback(
|
||||
(selected: string[]) => {
|
||||
// Always use the last selected value for single-select behavior
|
||||
const selectedValue =
|
||||
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
|
||||
onChange(selectedValue);
|
||||
const found = options.find((opt) => opt.value === selectedValue);
|
||||
if (setAbi) {
|
||||
setAbi(found?.abi || "");
|
||||
}
|
||||
setSearchValue("");
|
||||
},
|
||||
[onChange, setAbi, options],
|
||||
);
|
||||
|
||||
// Handle custom value entry
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && searchValue.trim()) {
|
||||
if (!options.some((opt) => opt.value === searchValue.trim())) {
|
||||
onChange(searchValue.trim());
|
||||
if (setAbi) setAbi("");
|
||||
setSearchValue("");
|
||||
// Optionally blur input
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Custom render for MultiSelect's search input
|
||||
const customSearchInput = (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={cn(
|
||||
"w-full border-0 border-border border-b bg-transparent py-4 pr-2 pl-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<MultiSelect
|
||||
options={allOptions}
|
||||
selectedValues={value ? [value] : []}
|
||||
onSelectedValuesChange={handleSelectedValuesChange}
|
||||
placeholder={placeholder}
|
||||
maxCount={1}
|
||||
disabled={disabled}
|
||||
searchPlaceholder={placeholder}
|
||||
customTrigger={null}
|
||||
renderOption={(option) => <span>{option.label}</span>}
|
||||
overrideSearchFn={(option, searchTerm) =>
|
||||
option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
}
|
||||
customSearchInput={customSearchInput}
|
||||
/>
|
||||
{isCustomValue && (
|
||||
<div className="mt-2 rounded border border-warning-200 bg-warning-50 px-2 py-1 text-warning-700 text-xs">
|
||||
You entered a custom signature. Please provide the ABI below.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
|
||||
@@ -159,6 +159,17 @@ export function TokenSelector(props: {
|
||||
? `${props.selectedToken.chainId}:${props.selectedToken.address}`
|
||||
: undefined;
|
||||
|
||||
// if selected value is not in options, add it
|
||||
if (
|
||||
selectedValue &&
|
||||
!options.find((option) => option.value === selectedValue)
|
||||
) {
|
||||
options.push({
|
||||
label: props.selectedToken?.address || "Unknown",
|
||||
value: selectedValue,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
searchPlaceholder="Search by name or symbol"
|
||||
@@ -175,7 +186,7 @@ export function TokenSelector(props: {
|
||||
showCheck={props.showCheck}
|
||||
placeholder={
|
||||
tokensQuery.isPending
|
||||
? "Loading Tokens..."
|
||||
? "Loading Tokens"
|
||||
: props.placeholder || "Select Token"
|
||||
}
|
||||
overrideSearchFn={searchFn}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ArrowRightIcon, RocketIcon, StarIcon } from "lucide-react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { UpsellBannerCard } from "./UpsellBannerCard";
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { formatDate } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
axisLine={false}
|
||||
tickMargin={20}
|
||||
tickFormatter={(value) =>
|
||||
formatDate(
|
||||
format(
|
||||
new Date(value),
|
||||
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { formatDate } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
@@ -86,7 +86,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => formatDate(new Date(value), "MMM d")}
|
||||
tickFormatter={(value) => format(new Date(value), "MMM d")}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
interface ClientOnlyProps {
|
||||
/**
|
||||
* Use this to server render a skeleton or loading state
|
||||
*/
|
||||
ssr: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ClientOnly: React.FC<ClientOnlyProps> = ({
|
||||
children,
|
||||
ssr,
|
||||
className,
|
||||
}) => {
|
||||
const hasMounted = useIsClientMounted();
|
||||
|
||||
if (!hasMounted) {
|
||||
return <> {ssr} </>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("fade-in-0 fill-mode-forwards ease-in", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useIsClientMounted() {
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
return hasMounted;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { type CodeEnvironment, CodeSegment } from "./code-segment.client";
|
||||
|
||||
@@ -10,6 +10,7 @@ type DistributionBarChartProps = {
|
||||
segments: Segment[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function DistributionBarChart(props: DistributionBarChartProps) {
|
||||
const totalPercentage = props.segments.reduce(
|
||||
(sum, segment) => sum + segment.percent,
|
||||
@@ -59,7 +60,13 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
|
||||
backgroundColor: segment.color,
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
(segment.percent > 100 || segment.percent < 0) &&
|
||||
"text-destructive-text",
|
||||
)}
|
||||
>
|
||||
{segment.label}: {segment.percent}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { DropZone } from "./drop-zone";
|
||||
|
||||
const meta = {
|
||||
title: "blocks/DropZone",
|
||||
component: DropZone,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container max-w-6xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof DropZone>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isError: false,
|
||||
onDrop: () => {},
|
||||
title: "This is a title",
|
||||
description: "This is a description for drop zone",
|
||||
accept: undefined,
|
||||
resetButton: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
isError: true,
|
||||
onDrop: () => {},
|
||||
title: "this is title",
|
||||
description: "This is a description",
|
||||
accept: undefined,
|
||||
resetButton: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorStateWithResetButton: Story = {
|
||||
args: {
|
||||
isError: true,
|
||||
onDrop: () => {},
|
||||
title: "this is title",
|
||||
description: "This is a description",
|
||||
accept: undefined,
|
||||
resetButton: {
|
||||
label: "Remove Files",
|
||||
onClick: () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UploadIcon, XIcon } from "lucide-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
export function DropZone(props: {
|
||||
isError: boolean;
|
||||
onDrop: (acceptedFiles: File[]) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
resetButton:
|
||||
| {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
| undefined;
|
||||
className?: string;
|
||||
accept: string | undefined;
|
||||
}) {
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: props.onDrop,
|
||||
});
|
||||
|
||||
const { resetButton } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center rounded-md border border-dashed bg-card py-10 hover:border-active-border",
|
||||
props.isError &&
|
||||
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
|
||||
props.className,
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} accept={props.accept} />
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
{!props.isError && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
|
||||
<UploadIcon className="size-5" />
|
||||
</div>
|
||||
<h2 className="mb-0.5 text-center font-medium text-lg">
|
||||
{props.title}
|
||||
</h2>
|
||||
<p className="text-center font-medium text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.isError && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
|
||||
<XIcon className="size-5" />
|
||||
</div>
|
||||
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
|
||||
{props.title}
|
||||
</h2>
|
||||
<p className="text-balance text-center text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
{resetButton && (
|
||||
<Button
|
||||
className="relative z-50 mt-4"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
resetButton.onClick();
|
||||
}}
|
||||
>
|
||||
{resetButton.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaRenderer, type MediaRendererProps } from "thirdweb/react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function CustomMediaRenderer(props: MediaRendererProps) {
|
||||
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (loadedSrc && loadedSrc !== props.src) {
|
||||
setLoadedSrc(undefined);
|
||||
}
|
||||
}, [loadedSrc, props.src]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-0"
|
||||
onLoad={() => {
|
||||
if (props.src) {
|
||||
setLoadedSrc(props.src);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!loadedSrc && <Skeleton className="absolute inset-0" />}
|
||||
<MediaRenderer
|
||||
{...props}
|
||||
className={cn(
|
||||
props.className,
|
||||
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative z-10",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { MultiSelect } from "./multi-select";
|
||||
|
||||
@@ -53,6 +53,7 @@ interface MultiSelectProps
|
||||
align?: "center" | "start" | "end";
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
showSelectedValuesInModal?: boolean;
|
||||
customSearchInput?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
@@ -69,6 +70,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
searchPlaceholder,
|
||||
popoverContentClassName,
|
||||
showSelectedValuesInModal = false,
|
||||
customSearchInput,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -233,15 +235,19 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={searchPlaceholder || "Search"}
|
||||
value={searchValue}
|
||||
// do not focus on the input when the popover opens to avoid opening the keyboard
|
||||
tabIndex={-1}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{customSearchInput ? (
|
||||
customSearchInput
|
||||
) : (
|
||||
<Input
|
||||
placeholder={searchPlaceholder || "Search"}
|
||||
value={searchValue}
|
||||
// do not focus on the input when the popover opens to avoid opening the keyboard
|
||||
tabIndex={-1}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
)}
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
|
||||
+12
-28
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { MultiStepStatus } from "./multi-step-status";
|
||||
|
||||
const meta = {
|
||||
@@ -16,50 +16,34 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const AllStates: Story = {
|
||||
args: {
|
||||
onRetry: () => {},
|
||||
steps: [
|
||||
{
|
||||
status: "completed",
|
||||
status: { type: "completed" },
|
||||
label: "Connect Wallet",
|
||||
retryLabel: "Failed to connect wallet",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "connect-wallet",
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
status: { type: "pending" },
|
||||
label: "Sign Message",
|
||||
retryLabel: "Failed to sign message",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "sign-message",
|
||||
},
|
||||
{
|
||||
status: "error",
|
||||
status: { type: "error", message: "This is an error message" },
|
||||
label: "Approve Transaction",
|
||||
retryLabel: "Transaction approval failed",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "approve-transaction",
|
||||
},
|
||||
{
|
||||
status: "idle",
|
||||
status: { type: "idle" },
|
||||
label: "Confirm Transaction",
|
||||
retryLabel: "Transaction confirmation failed",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "confirm-transaction",
|
||||
},
|
||||
{
|
||||
status: "idle",
|
||||
status: { type: "idle" },
|
||||
label: "Finalize",
|
||||
retryLabel: "Finalization failed",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "finalize",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,26 +10,34 @@ import {
|
||||
import { DynamicHeight } from "../../ui/DynamicHeight";
|
||||
import { Spinner } from "../../ui/Spinner/Spinner";
|
||||
|
||||
export type MultiStepState = {
|
||||
status: "idle" | "pending" | "completed" | "error";
|
||||
retryLabel: string;
|
||||
export type MultiStepState<T extends string> = {
|
||||
id: T;
|
||||
status:
|
||||
| {
|
||||
type: "idle" | "pending" | "completed";
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: React.ReactNode;
|
||||
};
|
||||
label: string;
|
||||
execute: () => Promise<void>;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function MultiStepStatus(props: {
|
||||
steps: MultiStepState[];
|
||||
export function MultiStepStatus<T extends string>(props: {
|
||||
steps: MultiStepState<T>[];
|
||||
onRetry: (step: MultiStepState<T>) => void;
|
||||
}) {
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className="space-y-4">
|
||||
{props.steps.map((step) => (
|
||||
<div key={step.label} className="flex items-start space-x-3 ">
|
||||
{step.status === "completed" ? (
|
||||
{step.status.type === "completed" ? (
|
||||
<CircleCheckIcon className="mt-0.5 size-5 flex-shrink-0 text-green-500" />
|
||||
) : step.status === "pending" ? (
|
||||
) : step.status.type === "pending" ? (
|
||||
<Spinner className="mt-0.5 size-5 flex-shrink-0 text-foreground" />
|
||||
) : step.status === "error" ? (
|
||||
) : step.status.type === "error" ? (
|
||||
<AlertCircleIcon className="mt-0.5 size-5 flex-shrink-0 text-red-500" />
|
||||
) : (
|
||||
<CircleIcon className="mt-0.5 size-5 flex-shrink-0 text-muted-foreground/70" />
|
||||
@@ -37,11 +45,11 @@ export function MultiStepStatus(props: {
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
step.status === "pending"
|
||||
step.status.type === "pending"
|
||||
? "text-foreground"
|
||||
: step.status === "completed"
|
||||
: step.status.type === "completed"
|
||||
? "text-green-500"
|
||||
: step.status === "error"
|
||||
: step.status.type === "error"
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground/70"
|
||||
}`}
|
||||
@@ -49,14 +57,25 @@ export function MultiStepStatus(props: {
|
||||
{step.label}
|
||||
</p>
|
||||
|
||||
{step.status === "error" && (
|
||||
{/* show description when this step is active */}
|
||||
{(step.status.type === "pending" ||
|
||||
step.status.type === "error") &&
|
||||
step.description && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step.status.type === "error" && (
|
||||
<div className="mt-1 space-y-2">
|
||||
<p className="mb-1 text-red-500 text-sm">{step.retryLabel}</p>
|
||||
<p className="mb-1 text-red-500 text-sm">
|
||||
{step.status.message}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => step.execute()}
|
||||
onClick={() => props.onRetry(step)}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Retry
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { BellIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { NotificationList } from "./notification-list";
|
||||
import { useNotifications } from "./state/manager";
|
||||
|
||||
export function NotificationsButton(props: { accountId: string }) {
|
||||
const manager = useNotifications(props.accountId);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<Button variant="outline" size="icon" className="relative rounded-full">
|
||||
<BellIcon className="h-4 w-4" />
|
||||
{(manager.unreadNotificationsCount || 0) > 0 && (
|
||||
<span className="absolute top-0 right-0 flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
),
|
||||
[manager.unreadNotificationsCount],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[90vh] min-h-[66vh]">
|
||||
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
|
||||
<NotificationList {...manager} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="max-h-[90vh] min-h-[500px] w-[400px] max-w-md p-0"
|
||||
align="end"
|
||||
>
|
||||
<NotificationList {...manager} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import type { Notification } from "@/api/notifications";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
format,
|
||||
formatDistanceToNow,
|
||||
isBefore,
|
||||
parseISO,
|
||||
subDays,
|
||||
} from "date-fns";
|
||||
import { ArchiveIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface NotificationEntryProps {
|
||||
notification: Notification;
|
||||
onMarkAsRead?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function NotificationEntry({
|
||||
notification,
|
||||
onMarkAsRead,
|
||||
}: NotificationEntryProps) {
|
||||
const timeAgo = useMemo(() => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const date = parseISO(notification.createdAt);
|
||||
// if the date is older than 1 day, show the date
|
||||
// otherwise, show the time ago
|
||||
|
||||
if (isBefore(date, subDays(now, 1))) {
|
||||
return format(date, "MMM d, yyyy");
|
||||
}
|
||||
|
||||
return formatDistanceToNow(date, {
|
||||
addSuffix: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to parse date", error);
|
||||
return null;
|
||||
}
|
||||
}, [notification.createdAt]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row py-1.5">
|
||||
{onMarkAsRead && (
|
||||
<div className="min-h-full w-1 shrink-0 rounded-r-lg bg-primary" />
|
||||
)}
|
||||
<div className="flex w-full flex-row justify-between gap-2 border-b px-4 py-2 transition-colors last:border-b-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm">{notification.description}</p>
|
||||
{timeAgo && (
|
||||
<p className="text-muted-foreground text-xs">{timeAgo}</p>
|
||||
)}
|
||||
<div className="flex flex-row justify-between gap-2 pt-1">
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a
|
||||
href={notification.ctaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{notification.ctaText}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onMarkAsRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onMarkAsRead(notification.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ArchiveIcon, BellIcon, Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "../../ui/badge";
|
||||
import { NotificationEntry } from "./notification-entry";
|
||||
import type { useNotifications } from "./state/manager";
|
||||
|
||||
export function NotificationList(props: ReturnType<typeof useNotifications>) {
|
||||
// default to inbox if there are unread notifications, otherwise default to archive
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
props.unreadNotificationsCount > 0
|
||||
? "inbox"
|
||||
: // if we have archived notifications, default to archive
|
||||
props.archivedNotifications.length > 0
|
||||
? "archive"
|
||||
: // otherwise defualt to inbox (if there are no archived notifications either)
|
||||
"inbox",
|
||||
);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 flex-col">
|
||||
<TabButtons
|
||||
tabContainerClassName="space-x-6 px-4 pt-2"
|
||||
tabClassName="!text-sm hover:!bg-transparent !px-0 !py-1"
|
||||
tabs={[
|
||||
{
|
||||
name: (
|
||||
<div className="flex items-center gap-1">
|
||||
Inbox
|
||||
{props.unreadNotificationsCount > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{props.unreadNotificationsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
onClick: () => setActiveTab("inbox"),
|
||||
isActive: activeTab === "inbox",
|
||||
},
|
||||
{
|
||||
name: "Archive",
|
||||
onClick: () => setActiveTab("archive"),
|
||||
isActive: activeTab === "archive",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="h-full w-full flex-1 overflow-y-auto"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
{activeTab === "inbox" ? (
|
||||
<InboxTab
|
||||
unreadNotifications={props.unreadNotifications}
|
||||
isLoadingUnread={props.isLoadingUnread}
|
||||
hasMoreUnread={props.hasMoreUnread}
|
||||
isFetchingMoreUnread={props.isFetchingMoreUnread}
|
||||
loadMoreUnread={props.loadMoreUnread}
|
||||
markAsRead={props.markAsRead}
|
||||
markAllAsRead={props.markAllAsRead}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
) : (
|
||||
<ArchiveTab
|
||||
archivedNotifications={props.archivedNotifications}
|
||||
isLoadingArchived={props.isLoadingArchived}
|
||||
hasMoreArchived={props.hasMoreArchived}
|
||||
isFetchingMoreArchived={props.isFetchingMoreArchived}
|
||||
loadMoreArchived={props.loadMoreArchived}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxTab(
|
||||
props: Pick<
|
||||
ReturnType<typeof useNotifications>,
|
||||
| "unreadNotifications"
|
||||
| "isLoadingUnread"
|
||||
| "hasMoreUnread"
|
||||
| "isFetchingMoreUnread"
|
||||
| "loadMoreUnread"
|
||||
| "markAsRead"
|
||||
| "markAllAsRead"
|
||||
> & {
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
},
|
||||
) {
|
||||
return props.unreadNotifications.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<BellIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">No new notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{props.unreadNotifications.map((notification) => (
|
||||
<NotificationEntry
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onMarkAsRead={props.markAsRead}
|
||||
/>
|
||||
))}
|
||||
<AutoLoadMore
|
||||
hasMore={props.hasMoreUnread}
|
||||
loadMore={props.loadMoreUnread}
|
||||
isLoading={props.isFetchingMoreUnread || props.isLoadingUnread}
|
||||
scrollContainerRef={props.scrollContainerRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveTab(
|
||||
props: Pick<
|
||||
ReturnType<typeof useNotifications>,
|
||||
| "archivedNotifications"
|
||||
| "isLoadingArchived"
|
||||
| "hasMoreArchived"
|
||||
| "isFetchingMoreArchived"
|
||||
| "loadMoreArchived"
|
||||
> & {
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
},
|
||||
) {
|
||||
return props.archivedNotifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<ArchiveIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">No archived notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{props.archivedNotifications.map((notification) => (
|
||||
<NotificationEntry key={notification.id} notification={notification} />
|
||||
))}
|
||||
<AutoLoadMore
|
||||
hasMore={props.hasMoreArchived}
|
||||
loadMore={props.loadMoreArchived}
|
||||
isLoading={props.isFetchingMoreArchived || props.isLoadingArchived}
|
||||
scrollContainerRef={props.scrollContainerRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoLoadMore(props: {
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
loadMore: () => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// if the element is scrolled into view, load more
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// only run if the ref and scroll container ref are defined
|
||||
if (ref.current && props.scrollContainerRef.current) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && !props.isLoading && props.hasMore) {
|
||||
props.loadMore();
|
||||
observer.unobserve(entry.target); // prevent duplicate fires
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: props.scrollContainerRef.current, threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(ref.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [
|
||||
props.hasMore,
|
||||
props.isLoading,
|
||||
props.loadMore,
|
||||
props.scrollContainerRef,
|
||||
]);
|
||||
|
||||
if (!props.hasMore) return null;
|
||||
|
||||
if (props.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div ref={ref} className="h-[1px] w-full opacity-0" aria-hidden />;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Notification,
|
||||
type NotificationsApiResponse,
|
||||
getArchivedNotifications,
|
||||
getUnreadNotifications,
|
||||
getUnreadNotificationsCount,
|
||||
markNotificationAsRead,
|
||||
} from "@/api/notifications";
|
||||
import {
|
||||
type InfiniteData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Internal helper to safely flatten pages coming from useInfiniteQuery.
|
||||
*/
|
||||
function flattenPages<T extends { result: Notification[] }>(
|
||||
data?: InfiniteData<T>,
|
||||
): Notification[] {
|
||||
return data?.pages.flatMap((p) => p.result) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that provides notifications state with optimistic archiving.
|
||||
*
|
||||
* Example:
|
||||
* ```tsx
|
||||
* const {
|
||||
* unreadNotifications,
|
||||
* archivedNotifications,
|
||||
* unreadCount,
|
||||
* markAsRead,
|
||||
* markAllAsRead,
|
||||
* loadMoreUnread,
|
||||
* loadMoreArchived,
|
||||
* hasMoreUnread,
|
||||
* hasMoreArchived,
|
||||
* } = useNotifications();
|
||||
* ```
|
||||
*/
|
||||
|
||||
export function useNotifications(accountId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// --------------------
|
||||
// Query definitions
|
||||
// --------------------
|
||||
|
||||
const unreadQueryKey = useMemo(
|
||||
() => ["notifications", "unread", { accountId }],
|
||||
[accountId],
|
||||
);
|
||||
const archivedQueryKey = useMemo(
|
||||
() => ["notifications", "archived", { accountId }],
|
||||
[accountId],
|
||||
);
|
||||
const unreadCountKey = useMemo(
|
||||
() => ["notifications", "unread-count", { accountId }],
|
||||
[accountId],
|
||||
);
|
||||
|
||||
const unreadQuery = useInfiniteQuery<NotificationsApiResponse>({
|
||||
queryKey: unreadQueryKey,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const cursor = (pageParam ?? undefined) as string | undefined;
|
||||
const res = await getUnreadNotifications(cursor);
|
||||
if (res.status === "error") {
|
||||
throw new Error(res.reason ?? "unknown");
|
||||
}
|
||||
return res.data;
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
|
||||
enabled: !!accountId,
|
||||
refetchInterval: 60_000, // 1min
|
||||
});
|
||||
|
||||
const archivedQuery = useInfiniteQuery<NotificationsApiResponse>({
|
||||
queryKey: archivedQueryKey,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const cursor = (pageParam ?? undefined) as string | undefined;
|
||||
const res = await getArchivedNotifications(cursor);
|
||||
if (res.status === "error") {
|
||||
throw new Error(res.reason ?? "unknown");
|
||||
}
|
||||
return res.data;
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
|
||||
enabled: !!accountId,
|
||||
refetchInterval: 60_000, // 1min
|
||||
});
|
||||
|
||||
const unreadCountQuery = useQuery({
|
||||
queryKey: unreadCountKey,
|
||||
queryFn: async () => {
|
||||
const res = await getUnreadNotificationsCount();
|
||||
if (res.status === "error") {
|
||||
throw new Error(res.reason ?? "unknown");
|
||||
}
|
||||
return res.data.result.unreadCount;
|
||||
},
|
||||
refetchInterval: 60_000, // 1min
|
||||
enabled: !!accountId,
|
||||
});
|
||||
|
||||
// --------------------
|
||||
// Mutation (archive)
|
||||
// --------------------
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (notificationId?: string) => {
|
||||
const res = await markNotificationAsRead(notificationId);
|
||||
if (res.status === "error") {
|
||||
toast.error("Failed to mark notification as read");
|
||||
throw new Error(res.reason ?? "unknown");
|
||||
}
|
||||
return { notificationId } as const;
|
||||
},
|
||||
// Optimistic update
|
||||
onMutate: async (notificationId) => {
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: unreadQueryKey }),
|
||||
queryClient.cancelQueries({ queryKey: archivedQueryKey }),
|
||||
]);
|
||||
|
||||
const previousUnread =
|
||||
queryClient.getQueryData<InfiniteData<NotificationsApiResponse>>(
|
||||
unreadQueryKey,
|
||||
);
|
||||
|
||||
const previousCount = queryClient.getQueryData<number>(unreadCountKey);
|
||||
|
||||
let optimisticUnread: InfiniteData<NotificationsApiResponse> | undefined;
|
||||
let optimisticCount: number | undefined;
|
||||
|
||||
if (previousUnread) {
|
||||
if (notificationId) {
|
||||
// Remove a single notification
|
||||
optimisticUnread = {
|
||||
...previousUnread,
|
||||
pages: previousUnread.pages.map((page) => ({
|
||||
...page,
|
||||
result: page.result.filter((n) => n.id !== notificationId),
|
||||
})),
|
||||
};
|
||||
if (typeof previousCount === "number") {
|
||||
optimisticCount = Math.max(previousCount - 1, 0);
|
||||
}
|
||||
} else {
|
||||
// Clear all unread notifications
|
||||
optimisticUnread = {
|
||||
...previousUnread,
|
||||
pages: previousUnread.pages.map((page) => ({
|
||||
...page,
|
||||
result: [],
|
||||
})),
|
||||
};
|
||||
optimisticCount = 0;
|
||||
}
|
||||
queryClient.setQueryData(unreadQueryKey, optimisticUnread);
|
||||
}
|
||||
|
||||
if (typeof optimisticCount === "number") {
|
||||
queryClient.setQueryData(unreadCountKey, optimisticCount);
|
||||
}
|
||||
|
||||
return { previousUnread, previousCount } as const;
|
||||
},
|
||||
// Rollback on error
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previousUnread) {
|
||||
queryClient.setQueryData(unreadQueryKey, context.previousUnread);
|
||||
}
|
||||
if (typeof context?.previousCount === "number") {
|
||||
queryClient.setQueryData(unreadCountKey, context.previousCount);
|
||||
}
|
||||
},
|
||||
// Always refetch to ensure consistency
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: unreadQueryKey });
|
||||
queryClient.invalidateQueries({ queryKey: archivedQueryKey });
|
||||
queryClient.invalidateQueries({ queryKey: unreadCountKey });
|
||||
},
|
||||
});
|
||||
|
||||
// --------------------
|
||||
// Derived helpers
|
||||
// --------------------
|
||||
const unreadNotifications = flattenPages(unreadQuery.data);
|
||||
const archivedNotifications = flattenPages(archivedQuery.data);
|
||||
const unreadNotificationsCount = unreadCountQuery.data ?? 0; // this is the total unread count
|
||||
|
||||
return {
|
||||
// data
|
||||
unreadNotifications,
|
||||
archivedNotifications,
|
||||
unreadNotificationsCount,
|
||||
|
||||
// booleans
|
||||
isLoadingUnread: unreadQuery.isLoading,
|
||||
isLoadingArchived: archivedQuery.isLoading,
|
||||
hasMoreUnread: unreadQuery.hasNextPage ?? false,
|
||||
hasMoreArchived: archivedQuery.hasNextPage ?? false,
|
||||
isFetchingMoreUnread: unreadQuery.isFetchingNextPage,
|
||||
isFetchingMoreArchived: archivedQuery.isFetchingNextPage,
|
||||
|
||||
// pagination helpers
|
||||
loadMoreUnread: () => unreadQuery.fetchNextPage(),
|
||||
loadMoreArchived: () => archivedQuery.fetchNextPage(),
|
||||
|
||||
// mutations
|
||||
markAsRead: (id: string) => archiveMutation.mutate(id),
|
||||
markAllAsRead: () => archiveMutation.mutate(undefined),
|
||||
} as const;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { CheckoutButton } from "../billing";
|
||||
|
||||
type PricingCardCta = {
|
||||
hint?: string;
|
||||
|
||||
onClick?: () => void;
|
||||
} & (
|
||||
| {
|
||||
@@ -58,12 +57,17 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
activeTrialEndsAt,
|
||||
}) => {
|
||||
const plan = TEAM_PLANS[billingPlan];
|
||||
const isCustomPrice = typeof plan.price === "string";
|
||||
|
||||
const trackEvent = useTrack();
|
||||
const remainingTrialDays =
|
||||
(activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0;
|
||||
|
||||
// if the team has just signed up and has not subscribed yet, and the billing plan is growth, then they get a 7 day trial
|
||||
const has7DayTrial =
|
||||
remainingTrialDays === 0 &&
|
||||
billingStatus === "noPayment" &&
|
||||
billingPlan === "growth";
|
||||
|
||||
const handleCTAClick = () => {
|
||||
cta?.onClick?.();
|
||||
trackEvent({
|
||||
@@ -97,6 +101,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
{plan.title}
|
||||
</h3>
|
||||
{current && <Badge className="capitalize">Current plan</Badge>}
|
||||
{has7DayTrial && <Badge variant="success">7 Day Free Trial</Badge>}
|
||||
</div>
|
||||
<p className="max-w-[320px] text-muted-foreground text-sm">
|
||||
{plan.description}
|
||||
@@ -108,15 +113,23 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
{plan.isStartingPriceOnly && (
|
||||
<span className="text-muted-foreground text-xs">Starting at</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-2xl text-foreground tracking-tight">
|
||||
${plan.price.toLocaleString()}
|
||||
</span>
|
||||
|
||||
{!isCustomPrice && (
|
||||
{has7DayTrial ? (
|
||||
<div className="flex flex-col items-start gap-0">
|
||||
<span className="font-bold text-2xl text-foreground tracking-tight">
|
||||
7 days free
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Then ${plan.price.toLocaleString()} / month
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-2xl text-foreground tracking-tight">
|
||||
${plan.price.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/ month</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remainingTrialDays > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -155,7 +168,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
teamSlug={teamSlug}
|
||||
sku={billingPlanToSkuMap[billingPlan]}
|
||||
>
|
||||
{cta.label}
|
||||
{has7DayTrial ? "Start 7 Day Free Trial" : cta.label}
|
||||
</CheckoutButton>
|
||||
)}
|
||||
|
||||
@@ -166,7 +179,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
asChild
|
||||
>
|
||||
<Link href={cta.href} target="_blank" onClick={handleCTAClick}>
|
||||
{cta.label}
|
||||
{has7DayTrial ? "Start 7 Day Free Trial" : cta.label}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -192,7 +205,6 @@ const billingPlanToSkuMap: Record<Team["billingPlan"], ProductSKU | undefined> =
|
||||
accelerate: undefined,
|
||||
free: undefined,
|
||||
growth_legacy: undefined,
|
||||
starter_legacy: undefined,
|
||||
};
|
||||
|
||||
type FeatureItemProps = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { SelectWithSearch } from "./select-with-search";
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { TeamPlanBadge } from "../../../app/(app)/components/TeamPlanBadge";
|
||||
import type { Team } from "../../api/team";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
interface UpsellWrapperProps {
|
||||
teamSlug: string;
|
||||
children: React.ReactNode;
|
||||
isLocked?: boolean;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
currentPlan?: Team["billingPlan"];
|
||||
featureName: string;
|
||||
featureDescription: string;
|
||||
benefits?: {
|
||||
description: string;
|
||||
status: "available" | "soon";
|
||||
}[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UpsellWrapper({
|
||||
teamSlug,
|
||||
children,
|
||||
isLocked = true,
|
||||
requiredPlan,
|
||||
currentPlan = "free",
|
||||
featureName,
|
||||
featureDescription,
|
||||
benefits = [],
|
||||
className,
|
||||
}: UpsellWrapperProps) {
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex-1", className)}>
|
||||
{/* Background content - blurred and non-interactive */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="pointer-events-none select-none opacity-60 blur-[1px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-muted/20 via-muted/30 to-background" />
|
||||
|
||||
{/* Upsell content */}
|
||||
<div className="relative z-10 flex items-center justify-center p-16">
|
||||
<Card className="w-full max-w-2xl border-2 shadow-2xl">
|
||||
<CardHeader className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full border-2 bg-muted">
|
||||
<LockIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TeamPlanBadge
|
||||
plan="scale"
|
||||
teamSlug={teamSlug}
|
||||
postfix=" Feature"
|
||||
/>
|
||||
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
|
||||
Unlock {featureName}
|
||||
</CardTitle>
|
||||
<CardDescription className="mx-auto max-w-md text-base text-muted-foreground">
|
||||
{featureDescription}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{benefits.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
|
||||
What you'll get:
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{benefits.map((benefit) => (
|
||||
<div
|
||||
key={benefit.description}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
|
||||
<SparklesIcon className="h-3 w-3 text-success-text" />
|
||||
</div>
|
||||
<span className="text-sm">{benefit.description}</span>
|
||||
{benefit.status === "soon" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Coming Soon
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-4 sm:flex-row">
|
||||
<Button className="flex-1 py-3 font-semibold" size="lg" asChild>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/billing?showPlans=true&highlight=${requiredPlan}`}
|
||||
>
|
||||
<CrownIcon className="mr-2 h-4 w-4" />
|
||||
Upgrade to{" "}
|
||||
<span className="ml-1 capitalize">{requiredPlan}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="md:flex-1" asChild>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/billing?showPlans=true`}
|
||||
>
|
||||
View All Plans
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
You are currently on the{" "}
|
||||
<span className="font-medium capitalize">{currentPlan}</span>{" "}
|
||||
plan.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function WalletAddress(props: {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
client: ThirdwebClient;
|
||||
preventOpenOnFocus?: boolean;
|
||||
}) {
|
||||
// default back to zero address if no address provided
|
||||
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
|
||||
@@ -64,7 +65,10 @@ export function WalletAddress(props: {
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<HoverCardTrigger
|
||||
asChild
|
||||
tabIndex={props.preventOpenOnFocus ? -1 : undefined}
|
||||
>
|
||||
<Button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="link"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ClientOnly } from "components/ClientOnly/ClientOnly";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { PaginationButtons } from "./pagination-buttons";
|
||||
|
||||
@@ -19,6 +19,7 @@ export const PaginationButtons = (props: {
|
||||
activePage: number;
|
||||
totalPages: number;
|
||||
onPageClick: (page: number) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { activePage, totalPages, onPageClick: setPage } = props;
|
||||
const [inputHasError, setInputHasError] = useState(false);
|
||||
@@ -40,11 +41,37 @@ export const PaginationButtons = (props: {
|
||||
}
|
||||
}
|
||||
|
||||
// if only two pages, show "prev" and "next"
|
||||
if (totalPages === 2) {
|
||||
return (
|
||||
<Pagination className={props.className}>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
disabled={activePage === 1}
|
||||
onClick={() => {
|
||||
setPage(activePage - 1);
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
disabled={activePage === totalPages}
|
||||
onClick={() => {
|
||||
setPage(activePage + 1);
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
|
||||
// just render all the page buttons directly
|
||||
if (totalPages <= 6) {
|
||||
const pages = [...Array(totalPages)].map((_, i) => i + 1);
|
||||
return (
|
||||
<Pagination>
|
||||
<Pagination className={props.className}>
|
||||
<PaginationContent>
|
||||
{pages.map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
@@ -64,7 +91,7 @@ export const PaginationButtons = (props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<Pagination className={props.className}>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
inset: 0px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #fff;
|
||||
animation: prixClipFix 2s linear infinite;
|
||||
stroke-linecap: round;
|
||||
animation: dash 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { StarIcon } from "lucide-react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
|
||||
|
||||
@@ -9,17 +9,16 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
"bg-primary hover:bg-primary/90 text-semibold text-primary-foreground ",
|
||||
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
|
||||
default: "bg-foreground text-background hover:bg-foreground/90",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 text-semibold text-destructive-foreground ",
|
||||
"bg-destructive hover:bg-destructive/90 text-destructive-foreground ",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground text-semibold",
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary hover:bg-secondary/80 text-semibold text-secondary-foreground ",
|
||||
ghost: "hover:bg-accent text-semibold hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline text-semibold",
|
||||
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "underline-offset-4 hover:underline",
|
||||
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
|
||||
upsell:
|
||||
"bg-green-600 text-white hover:bg-green-700 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
|
||||
@@ -45,17 +44,35 @@ export interface ButtonProps
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
// "button" elements automatically handle the `disabled` attribute.
|
||||
// For non-button elements rendered via `asChild` (e.g. <a>), we still want
|
||||
// to visually convey the disabled state and prevent user interaction.
|
||||
// We do that by conditionally adding the same utility classes that the
|
||||
// `disabled:` pseudo-variant would normally apply and by setting
|
||||
// `aria-disabled` for accessibility.
|
||||
const disabledClass = disabled ? "pointer-events-none opacity-50" : "";
|
||||
|
||||
const btnOnlyProps =
|
||||
Comp === "button"
|
||||
? { type: props.type || ("button" as const) }
|
||||
? {
|
||||
type:
|
||||
(props as React.ButtonHTMLAttributes<HTMLButtonElement>).type ||
|
||||
("button" as const),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
disabledClass,
|
||||
)}
|
||||
ref={ref}
|
||||
aria-disabled={disabled ? true : undefined}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
{...btnOnlyProps}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { CodeClient } from "./code.client";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import { PlainTextCodeBlock } from "./plaintext-code";
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export function DecimalInput(props: {
|
||||
maxValue?: number;
|
||||
id?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
@@ -13,6 +15,8 @@ export function DecimalInput(props: {
|
||||
value={props.value}
|
||||
className={props.className}
|
||||
inputMode="decimal"
|
||||
placeholder={props.placeholder}
|
||||
disabled={props.disabled}
|
||||
onChange={(e) => {
|
||||
const number = Number(e.target.value);
|
||||
// ignore if string becomes invalid number
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"font-semibold text-lg leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
|
||||
const meta = {
|
||||
|
||||
@@ -16,7 +16,7 @@ function SkeletonContainer<T>(props: {
|
||||
loadedData?: T;
|
||||
skeletonData: T;
|
||||
className?: string;
|
||||
render: (data: T, isSkeleton: boolean) => React.ReactNode;
|
||||
render: (data: T) => React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const isLoading = props.loadedData === undefined;
|
||||
@@ -40,7 +40,6 @@ function SkeletonContainer<T>(props: {
|
||||
props.loadedData === undefined
|
||||
? props.skeletonData
|
||||
: props.loadedData,
|
||||
!isLoading,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import Link from "next/link";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@@ -22,6 +22,7 @@ export function TabLinks(props: {
|
||||
tabContainerClassName?: string;
|
||||
shadowColor?: string;
|
||||
scrollableClassName?: string;
|
||||
bottomLineClassName?: string;
|
||||
}) {
|
||||
const { containerRef, lineRef, activeTabRef } =
|
||||
useUnderline<HTMLAnchorElement>();
|
||||
@@ -29,7 +30,12 @@ export function TabLinks(props: {
|
||||
return (
|
||||
<div className={cn("relative", props.className)}>
|
||||
{/* Bottom line */}
|
||||
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 left-0 h-[1px] bg-border",
|
||||
props.bottomLineClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollShadow
|
||||
scrollableClassName={cn("pb-[8px] relative", props.scrollableClassName)}
|
||||
@@ -216,6 +222,7 @@ export function TabPathLinks(props: {
|
||||
tabContainerClassName?: string;
|
||||
shadowColor?: string;
|
||||
scrollableClassName?: string;
|
||||
bottomLineClassName?: string;
|
||||
}) {
|
||||
const pathname = usePathname() || "";
|
||||
const { links, ...restProps } = props;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export const NEXT_PUBLIC_DASHBOARD_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "";
|
||||
|
||||
export const NEXT_PUBLIC_NEBULA_APP_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_NEBULA_APP_CLIENT_ID || "";
|
||||
|
||||
export const NEXT_PUBLIC_THIRDWEB_VAULT_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_VAULT_URL || "";
|
||||
|
||||
@@ -30,7 +27,5 @@ export const NEXT_PUBLIC_TURNSTILE_SITE_KEY =
|
||||
export const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
|
||||
|
||||
export const NEXT_PUBLIC_NEBULA_URL = process.env.NEXT_PUBLIC_NEBULA_URL || "";
|
||||
|
||||
export const NEXT_PUBLIC_DEMO_ENGINE_URL =
|
||||
process.env.NEXT_PUBLIC_DEMO_ENGINE_URL || "";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import "server-only";
|
||||
import { experimental_taintUniqueValue } from "react";
|
||||
import { experimental_taintUniqueValue as _experimental_taintUniqueValue } from "react";
|
||||
import { isProd } from "./env-utils";
|
||||
|
||||
// on storybook, experimental_taintUniqueValue is not available, because its not available in the react version we are using - its added by next.js
|
||||
const experimental_taintUniqueValue =
|
||||
_experimental_taintUniqueValue || (() => {});
|
||||
|
||||
// Make sure to taint the server only envs here with experimental_taintUniqueValue ONLY if they contain a UNIQUE sensitive value
|
||||
// if an env has a generic value that may appear naturally in client components - do not taint it
|
||||
|
||||
@@ -14,16 +18,6 @@ experimental_taintUniqueValue(
|
||||
DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
);
|
||||
|
||||
export const NEBULA_APP_SECRET_KEY = process.env.NEBULA_APP_SECRET_KEY || "";
|
||||
|
||||
if (NEBULA_APP_SECRET_KEY) {
|
||||
experimental_taintUniqueValue(
|
||||
"Do not pass NEBULA_APP_SECRET_KEY to the client",
|
||||
process,
|
||||
NEBULA_APP_SECRET_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
export const API_SERVER_SECRET = process.env.API_SERVER_SECRET || "";
|
||||
|
||||
if (API_SERVER_SECRET) {
|
||||
|
||||
@@ -42,10 +42,13 @@ export function getConfiguredThirdwebClient(options: {
|
||||
|
||||
if (!getTransactionDecorator()) {
|
||||
setTransactionDecorator(async ({ account, transaction }) => {
|
||||
// special override for sophon testnet (zk chain)
|
||||
// special override for sophon (zk chain)
|
||||
// sophon only allows transactions through their paymaster
|
||||
// so always use eip712 tx + paymaster
|
||||
if (transaction.chain.id === 531050104) {
|
||||
if (
|
||||
transaction.chain.id === 531050104 ||
|
||||
transaction.chain.id === 50104
|
||||
) {
|
||||
const serializedTx = await populateEip712Transaction({
|
||||
transaction,
|
||||
account,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function fileToBlobUrl(file: File) {
|
||||
const blob = new Blob([file], { type: file.type });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
@@ -70,15 +70,25 @@ export type BackendWallet = {
|
||||
export function useEngineBackendWallets(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) {
|
||||
const { instanceUrl, authToken } = params;
|
||||
return useQuery({
|
||||
queryKey: [...engineKeys.backendWallets(instanceUrl), authToken],
|
||||
queryKey: [
|
||||
...engineKeys.backendWallets(instanceUrl),
|
||||
authToken,
|
||||
params.limit,
|
||||
params.page,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${instanceUrl}backend-wallet/get-all?limit=50`, {
|
||||
method: "GET",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
});
|
||||
const res = await fetch(
|
||||
`${instanceUrl}backend-wallet/get-all?limit=${params.limit ?? 1000}&page=${params.page ?? 1}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getAuthToken } from "@app/api/lib/getAuthToken";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { SearchInput } from "./components/client/search";
|
||||
import { QueryType } from "./components/client/type";
|
||||
import { RouteListView } from "./components/client/view";
|
||||
|
||||
+7
-1
@@ -49,6 +49,7 @@ import {
|
||||
useSwitchActiveWalletChain,
|
||||
useWalletBalance,
|
||||
} from "thirdweb/react";
|
||||
import { parseError } from "utils/errorParser";
|
||||
import { z } from "zod";
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
@@ -234,7 +235,12 @@ export function FaucetButton({
|
||||
const claimPromise = claimMutation.mutateAsync(values.turnstileToken);
|
||||
toast.promise(claimPromise, {
|
||||
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
|
||||
error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
|
||||
error: (err) => {
|
||||
return {
|
||||
message: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
|
||||
description: parseError(err),
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ export function PayModalButton(props: {
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
className="!w-auto"
|
||||
payOptions={{
|
||||
onPurchaseSuccess(info) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: false positive
|
||||
onPurchaseSuccess(info: any) {
|
||||
if (
|
||||
info.type === "crypto" &&
|
||||
info.status.status !== "NOT_FOUND"
|
||||
|
||||
@@ -14,16 +14,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "@app/api/lib/getAuthToken";
|
||||
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 { NebulaChatButton } from "../../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../../api/lib/getAuthToken";
|
||||
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
@@ -61,10 +57,9 @@ export default async function ChainPageLayout(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
const [chain, authToken, accountAddress] = await Promise.all([
|
||||
const [chain, authToken] = await Promise.all([
|
||||
getChain(params.chain_id),
|
||||
getAuthToken(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
if (params.chain_id !== chain.slug) {
|
||||
@@ -74,49 +69,12 @@ export default async function ChainPageLayout(props: {
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const client = getClientThirdwebClient(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 (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
<NebulaChatButton
|
||||
isLoggedIn={!!authToken}
|
||||
networks={chain.testnet ? "testnet" : "mainnet"}
|
||||
isFloating={true}
|
||||
pageType="chain"
|
||||
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>
|
||||
|
||||
+26
-26
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useCsvUpload } from "hooks/useCsvUpload";
|
||||
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import type { Column } from "react-table";
|
||||
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import { CsvDataTable } from "../csv-data-table";
|
||||
@@ -52,23 +53,18 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
onClose,
|
||||
client,
|
||||
}) => {
|
||||
const {
|
||||
normalizeQuery,
|
||||
getInputProps,
|
||||
getRootProps,
|
||||
isDragActive,
|
||||
rawData,
|
||||
noCsv,
|
||||
reset,
|
||||
removeInvalid,
|
||||
} = useCsvUpload<SnapshotAddressInput>({
|
||||
const csvUpload = useCsvUpload<SnapshotAddressInput>({
|
||||
csvParser,
|
||||
defaultRawData: value,
|
||||
client,
|
||||
});
|
||||
|
||||
const dropzone = useDropzone({
|
||||
onDrop: csvUpload.setFiles,
|
||||
});
|
||||
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
const normalizeData = normalizeQuery.data;
|
||||
const normalizeData = csvUpload.normalizeQuery.data;
|
||||
|
||||
if (!normalizeData) {
|
||||
return (
|
||||
@@ -152,11 +148,11 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{rawData.length > 0 ? (
|
||||
{csvUpload.rawData.length > 0 ? (
|
||||
<div>
|
||||
<CsvDataTable<SnapshotAddressInput>
|
||||
portalRef={paginationPortalRef}
|
||||
data={normalizeQuery.data.result}
|
||||
data={csvUpload.normalizeQuery.data.result}
|
||||
columns={columns}
|
||||
/>
|
||||
</div>
|
||||
@@ -166,23 +162,27 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
|
||||
noCsv ? "bg-red-200" : "bg-background",
|
||||
csvUpload.noCsv ? "bg-red-200" : "bg-background",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
{...dropzone.getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<input {...dropzone.getInputProps()} />
|
||||
<div className="!m-auto flex flex-col">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
noCsv ? "text-red-500" : "text-gray-600",
|
||||
csvUpload.noCsv ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
/>
|
||||
{isDragActive ? (
|
||||
{dropzone.isDragActive ? (
|
||||
<p>Drop the files here</p>
|
||||
) : (
|
||||
<p className={noCsv ? "text-red-500" : "text-gray-600"}>
|
||||
{noCsv
|
||||
<p
|
||||
className={
|
||||
csvUpload.noCsv ? "text-red-500" : "text-gray-600"
|
||||
}
|
||||
>
|
||||
{csvUpload.noCsv
|
||||
? `No valid CSV file found, make sure your CSV includes the "address" column.`
|
||||
: "Drag & Drop a CSV file here"}
|
||||
</p>
|
||||
@@ -294,20 +294,20 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
|
||||
<Button
|
||||
className="w-full rounded-md md:w-auto"
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
reset();
|
||||
csvUpload.reset();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{normalizeQuery.data?.invalidFound ? (
|
||||
{csvUpload.normalizeQuery.data?.invalidFound ? (
|
||||
<Button
|
||||
className="w-full rounded-md md:w-auto"
|
||||
variant="primary"
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
removeInvalid();
|
||||
csvUpload.removeInvalid();
|
||||
}}
|
||||
>
|
||||
Remove invalid
|
||||
@@ -317,7 +317,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
className="w-full rounded-md md:w-auto"
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
+26
-4
@@ -1,8 +1,10 @@
|
||||
import { resolveFunctionSelectors } from "lib/selectors";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { isERC20 } from "thirdweb/extensions/erc20";
|
||||
import { isGetNFTsSupported as ERC721_isGetNFTsSupported } from "thirdweb/extensions/erc721";
|
||||
import { isGetNFTsSupported as ERC1155_isGetNFTsSupported } from "thirdweb/extensions/erc1155";
|
||||
import { supportedERCs } from "./detectedFeatures/supportedERCs";
|
||||
|
||||
export type NewPublicPageType = "erc20";
|
||||
export type NewPublicPageType = "erc20" | "erc1155" | "erc721";
|
||||
|
||||
export async function shouldRenderNewPublicPage(
|
||||
contract: ThirdwebContract,
|
||||
@@ -16,9 +18,29 @@ export async function shouldRenderNewPublicPage(
|
||||
return false;
|
||||
}
|
||||
|
||||
const isERC20Contract = isERC20(functionSelectors);
|
||||
const _supportedERCs = supportedERCs(functionSelectors);
|
||||
|
||||
if (isERC20Contract) {
|
||||
// ERC1155
|
||||
if (
|
||||
_supportedERCs.isERC1155 &&
|
||||
ERC1155_isGetNFTsSupported(functionSelectors)
|
||||
) {
|
||||
return {
|
||||
type: "erc1155",
|
||||
};
|
||||
}
|
||||
|
||||
// ERC721
|
||||
if (
|
||||
_supportedERCs.isERC721 &&
|
||||
ERC721_isGetNFTsSupported(functionSelectors)
|
||||
) {
|
||||
return {
|
||||
type: "erc721",
|
||||
};
|
||||
}
|
||||
|
||||
if (_supportedERCs.isERC20) {
|
||||
return {
|
||||
type: "erc20",
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Flex, SimpleGrid, useBreakpointValue } from "@chakra-ui/react";
|
||||
import { formatDistance } from "date-fns/formatDistance";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import {
|
||||
useTotalContractTransactionAnalytics,
|
||||
useTotalContractUniqueWallets,
|
||||
} from "data/analytics/hooks";
|
||||
import { formatDate } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
|
||||
@@ -125,7 +125,7 @@ type ChartProps = {
|
||||
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 format(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { getAuthToken } from "@app/api/lib/getAuthToken";
|
||||
import type { Metadata } from "next";
|
||||
import { getAuthToken } from "../../../../api/lib/getAuthToken";
|
||||
import {
|
||||
SharedContractLayout,
|
||||
generateContractLayoutMetadata,
|
||||
|
||||
+5
-1
@@ -199,7 +199,11 @@ function UploadMetadataNFTSection(props: {
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
{/* Left */}
|
||||
<div className="shrink-0 lg:w-[300px]">
|
||||
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
|
||||
<NFTMediaFormGroup
|
||||
form={form}
|
||||
previewMaxWidth="300px"
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
|
||||
+5
-1
@@ -397,7 +397,11 @@ function MintNFTSection(props: {
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
{/* Left */}
|
||||
<div className="shrink-0 lg:w-[300px]">
|
||||
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
|
||||
<NFTMediaFormGroup
|
||||
form={form}
|
||||
previewMaxWidth="300px"
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { TransactionError } from "contexts/error-handler";
|
||||
import { useState } from "react";
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { subDays } from "date-fns";
|
||||
import { useState } from "react";
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer, storybookThirdwebClient } from "stories/utils";
|
||||
|
||||
+19
-69
@@ -1,89 +1,37 @@
|
||||
"use client";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
import {
|
||||
getUploadedNFTMediaMeta,
|
||||
handleNFTMediaUpload,
|
||||
} from "./handleNFTMediaUpload";
|
||||
|
||||
type NFTMediaFormGroupValues = {
|
||||
image?: NFTInput["image"];
|
||||
animation_url?: NFTInput["animation_url"];
|
||||
external_url?: NFTInput["external_url"];
|
||||
};
|
||||
|
||||
export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
|
||||
form: UseFormReturn<T>;
|
||||
previewMaxWidth?: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
// T contains all properties of NFTMediaFormGroupValues, so this is a-ok
|
||||
const form = props.form as unknown as UseFormReturn<NFTMediaFormGroupValues>;
|
||||
|
||||
const setFile = (file: File) => {
|
||||
const external_url = form.watch("external_url");
|
||||
const animation_url = form.watch("animation_url");
|
||||
|
||||
if (file.type.includes("image")) {
|
||||
form.setValue("image", file);
|
||||
if (external_url instanceof File) {
|
||||
form.setValue("external_url", undefined);
|
||||
}
|
||||
if (animation_url instanceof File) {
|
||||
form.setValue("animation_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["audio", "video", "text/html", "model/*"].some((type: string) =>
|
||||
file.type.includes(type),
|
||||
) ||
|
||||
file.name?.endsWith(".glb") ||
|
||||
file.name?.endsWith(".usdz") ||
|
||||
file.name?.endsWith(".gltf") ||
|
||||
file.name.endsWith(".obj")
|
||||
) {
|
||||
// audio, video, html, and glb (3d) files
|
||||
form.setValue("animation_url", file);
|
||||
if (external_url instanceof File) {
|
||||
form.setValue("external_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["text", "application/pdf"].some((type: string) =>
|
||||
file.type?.includes(type),
|
||||
)
|
||||
) {
|
||||
// text and pdf files
|
||||
form.setValue("external_url", file);
|
||||
if (animation_url instanceof File) {
|
||||
form.setValue("animation_url", undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const external_url = form.watch("external_url");
|
||||
const animation_url = form.watch("animation_url");
|
||||
const image = form.watch("image");
|
||||
const errors = form.formState.errors;
|
||||
|
||||
const imageUrl = useImageFileOrUrl(image as File | string);
|
||||
const showCoverImageUpload =
|
||||
animation_url instanceof File || external_url instanceof File;
|
||||
|
||||
const mediaFileUrl =
|
||||
animation_url instanceof File
|
||||
? animation_url
|
||||
: external_url instanceof File
|
||||
? external_url
|
||||
: image instanceof File
|
||||
? imageUrl
|
||||
: undefined;
|
||||
|
||||
const mediaFileError =
|
||||
animation_url instanceof File
|
||||
? errors?.animation_url
|
||||
: external_url instanceof File
|
||||
? errors?.external_url
|
||||
: image instanceof File
|
||||
? errors?.image
|
||||
: undefined;
|
||||
const { media, image, mediaFileError, showCoverImageUpload } =
|
||||
getUploadedNFTMediaMeta(form);
|
||||
|
||||
const previewMaxWidth = props.previewMaxWidth ?? "200px";
|
||||
|
||||
const setFile = (file: File) => {
|
||||
handleNFTMediaUpload({ file, form });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormFieldSetup
|
||||
@@ -97,11 +45,12 @@ export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
|
||||
</p>
|
||||
<FileInput
|
||||
previewMaxWidth={previewMaxWidth}
|
||||
value={mediaFileUrl as File | string}
|
||||
value={media}
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
className="max-sm:!max-w-full rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
client={props.client}
|
||||
helperText="Media"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
@@ -112,12 +61,13 @@ export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
|
||||
htmlFor="cover-image"
|
||||
label="Cover Image"
|
||||
tooltip="You can optionally upload an image as the cover of your NFT."
|
||||
isRequired
|
||||
isRequired={false}
|
||||
>
|
||||
<FileInput
|
||||
previewMaxWidth={previewMaxWidth}
|
||||
client={props.client}
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
value={image}
|
||||
showUploadButton
|
||||
setValue={(file) => {
|
||||
form.setValue("image", file);
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
|
||||
type MinimalNFTMetadata = {
|
||||
image?: NFTInput["image"];
|
||||
animation_url?: NFTInput["animation_url"];
|
||||
external_url?: NFTInput["external_url"];
|
||||
};
|
||||
|
||||
export function handleNFTMediaUpload<T extends MinimalNFTMetadata>(params: {
|
||||
file: File;
|
||||
form: UseFormReturn<T>;
|
||||
}) {
|
||||
const { file } = params;
|
||||
const form = params.form as unknown as UseFormReturn<MinimalNFTMetadata>;
|
||||
|
||||
const external_url = form.getValues("external_url");
|
||||
const animation_url = form.getValues("animation_url");
|
||||
|
||||
if (file.type.includes("image")) {
|
||||
form.setValue("image", file);
|
||||
|
||||
if (external_url instanceof File) {
|
||||
form.setValue("external_url", undefined);
|
||||
}
|
||||
|
||||
if (animation_url instanceof File) {
|
||||
form.setValue("animation_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["audio", "video", "text/html", "model/"].some((type: string) =>
|
||||
file.type.includes(type),
|
||||
) ||
|
||||
file.name?.endsWith(".glb") ||
|
||||
file.name?.endsWith(".usdz") ||
|
||||
file.name?.endsWith(".gltf") ||
|
||||
file.name.endsWith(".obj")
|
||||
) {
|
||||
// audio, video, html, and glb (3d) files
|
||||
form.setValue("animation_url", file);
|
||||
if (external_url instanceof File) {
|
||||
form.setValue("external_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["text", "application/pdf"].some((type: string) =>
|
||||
file.type?.includes(type),
|
||||
)
|
||||
) {
|
||||
// text and pdf files
|
||||
form.setValue("external_url", file);
|
||||
if (animation_url instanceof File) {
|
||||
form.setValue("animation_url", undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getUploadedNFTMediaMeta<T extends MinimalNFTMetadata>(
|
||||
_form: UseFormReturn<T>,
|
||||
) {
|
||||
const form = _form as unknown as UseFormReturn<MinimalNFTMetadata>;
|
||||
|
||||
const _animation_url = form.watch("animation_url");
|
||||
const _external_url = form.watch("external_url");
|
||||
const _image = form.watch("image");
|
||||
const _media = _animation_url || _external_url || _image;
|
||||
const errors = form.formState.errors;
|
||||
|
||||
return {
|
||||
media: stringOrFile(_media),
|
||||
image: stringOrFile(_image),
|
||||
mediaFileError:
|
||||
errors?.animation_url || errors.external_url || errors?.image,
|
||||
showCoverImageUpload: !!_animation_url || !!_external_url,
|
||||
animation_url: stringOrFile(_animation_url),
|
||||
external_url: stringOrFile(_external_url),
|
||||
};
|
||||
}
|
||||
|
||||
function stringOrFile(value: unknown): string | File | undefined {
|
||||
if (typeof value === "string" || value instanceof File) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { parseError } from "utils/errorParser";
|
||||
|
||||
interface ClaimTabProps {
|
||||
contract: ThirdwebContract;
|
||||
@@ -67,7 +68,12 @@ const ClaimTabERC1155: React.FC<ClaimTabProps> = ({
|
||||
toast.promise(promise, {
|
||||
loading: "Claiming NFT",
|
||||
success: "NFT claimed successfully",
|
||||
error: "Failed to claim NFT",
|
||||
error: (error) => {
|
||||
return {
|
||||
message: "Failed to claim NFT",
|
||||
description: parseError(error),
|
||||
};
|
||||
},
|
||||
});
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
|
||||
+67
-130
@@ -15,7 +15,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { type Dispatch, type SetStateAction, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -38,9 +37,12 @@ import {
|
||||
FormLabel,
|
||||
Heading,
|
||||
} from "tw-components";
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
import type { NFTMetadataInputLimited } from "types/modified-types";
|
||||
import { parseAttributes } from "utils/parseAttributes";
|
||||
import {
|
||||
getUploadedNFTMediaMeta,
|
||||
handleNFTMediaUpload,
|
||||
} from "../../../modules/components/nft/handleNFTMediaUpload";
|
||||
|
||||
const UPDATE_METADATA_FORM_ID = "nft-update-metadata-form";
|
||||
|
||||
@@ -83,6 +85,11 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
return nftMetadata;
|
||||
}, [nft]);
|
||||
|
||||
const form = useForm<NFTMetadataInputLimited>({
|
||||
defaultValues: transformedQueryData,
|
||||
values: transformedQueryData,
|
||||
});
|
||||
|
||||
const {
|
||||
setValue,
|
||||
control,
|
||||
@@ -90,78 +97,20 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<NFTMetadataInputLimited>({
|
||||
defaultValues: transformedQueryData,
|
||||
values: transformedQueryData,
|
||||
});
|
||||
} = form;
|
||||
|
||||
const setFile = (file: File) => {
|
||||
if (file.type.includes("image")) {
|
||||
// image files
|
||||
setValue("image", file);
|
||||
if (watch("external_url") instanceof File) {
|
||||
setValue("external_url", undefined);
|
||||
}
|
||||
if (watch("animation_url") instanceof File) {
|
||||
setValue("animation_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["audio", "video", "text/html", "model/*"].some((type: string) =>
|
||||
file.type.includes(type),
|
||||
) ||
|
||||
file.name?.endsWith(".glb") ||
|
||||
file.name?.endsWith(".usdz") ||
|
||||
file.name?.endsWith(".gltf") ||
|
||||
file.name.endsWith(".obj")
|
||||
) {
|
||||
// audio, video, html, and glb (3d) files
|
||||
setValue("animation_url", file);
|
||||
if (watch("external_url") instanceof File) {
|
||||
setValue("external_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["text", "application/pdf"].some((type: string) =>
|
||||
file.type?.includes(type),
|
||||
)
|
||||
) {
|
||||
// text and pdf files
|
||||
setValue("external_url", file);
|
||||
if (watch("animation_url") instanceof File) {
|
||||
setValue("animation_url", undefined);
|
||||
}
|
||||
}
|
||||
handleNFTMediaUpload({ file, form });
|
||||
};
|
||||
const {
|
||||
media,
|
||||
image,
|
||||
mediaFileError,
|
||||
showCoverImageUpload,
|
||||
animation_url,
|
||||
external_url,
|
||||
} = getUploadedNFTMediaMeta(form);
|
||||
|
||||
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
|
||||
const animationUrlFormValue = watch("animation_url");
|
||||
const imageUrlFormValue = watch("image");
|
||||
|
||||
const mediaFileUrl =
|
||||
watch("animation_url") instanceof File
|
||||
? watch("animation_url")
|
||||
: watch("external_url") instanceof File
|
||||
? watch("external_url")
|
||||
: watch("image") instanceof File
|
||||
? imageUrl
|
||||
: undefined;
|
||||
|
||||
const mediaFileError =
|
||||
watch("animation_url") instanceof File
|
||||
? errors?.animation_url
|
||||
: watch("external_url") instanceof File
|
||||
? errors?.external_url
|
||||
: watch("image") instanceof File
|
||||
? errors?.image
|
||||
: undefined;
|
||||
|
||||
const externalUrl = watch("external_url");
|
||||
const externalIsTextFile =
|
||||
externalUrl instanceof File &&
|
||||
(externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
|
||||
|
||||
const showCoverImageUpload =
|
||||
watch("animation_url") instanceof File ||
|
||||
watch("external_url") instanceof File;
|
||||
const sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
const updateMetadataNotifications = useTxNotifications(
|
||||
"NFT metadata updated successfully",
|
||||
@@ -247,37 +196,20 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
<Input autoFocus {...register("name")} />
|
||||
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!mediaFileError}>
|
||||
<FormLabel>Media</FormLabel>
|
||||
<div className="flex flex-row flex-wrap gap-3">
|
||||
{nft?.metadata && !mediaFileUrl && (
|
||||
<NFTMediaWithEmptyState
|
||||
client={contract.client}
|
||||
metadata={{
|
||||
name: nft.metadata.name,
|
||||
animation_url:
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: nft.metadata.animation_url,
|
||||
image:
|
||||
typeof imageUrlFormValue === "string"
|
||||
? imageUrlFormValue
|
||||
: nft.metadata.image,
|
||||
}}
|
||||
width="200px"
|
||||
height="200px"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
value={mediaFileUrl as File | string}
|
||||
value={media}
|
||||
showUploadButton
|
||||
showPreview={nft?.metadata ? !!mediaFileUrl : true}
|
||||
showPreview
|
||||
setValue={setFile}
|
||||
className="shrink-0 rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText={nft?.metadata ? "New Media" : "Media"}
|
||||
client={contract.client}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -289,13 +221,15 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
{mediaFileError?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{showCoverImageUpload && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
client={contract.client}
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
value={image}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="rounded border border-border transition-all"
|
||||
@@ -320,6 +254,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
control={control}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
client={contract.client}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
@@ -344,7 +279,8 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
{errors?.background_color?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{!externalIsTextFile && (
|
||||
|
||||
{!(external_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.external_url}>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
@@ -360,42 +296,43 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
value={
|
||||
typeof imageUrlFormValue === "string" ? imageUrlFormValue : ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded to a URL, you
|
||||
can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
value={
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{!(image instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
value={typeof image === "string" ? image : ""}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded to a URL, you
|
||||
can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
value={typeof animation_url === "string" ? animation_url : ""}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media
|
||||
file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
@@ -415,7 +352,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
isPending={sendAndConfirmTx.isPending}
|
||||
form={UPDATE_METADATA_FORM_ID}
|
||||
type="submit"
|
||||
disabled={!isDirty && imageUrl === nft?.metadata.image}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Update NFT
|
||||
</TransactionButton>
|
||||
|
||||
+18
@@ -5,6 +5,8 @@ import { redirectToContractLandingPage } from "../../../../../../team/[team_slug
|
||||
import { buildContractPagePath } from "../../_utils/contract-page-path";
|
||||
import { getContractPageParamsInfo } from "../../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../../_utils/getContractPageMetadata";
|
||||
import { shouldRenderNewPublicPage } from "../../_utils/newPublicPage";
|
||||
import { NFTPublicPage } from "../../public-pages/nft/nft-page";
|
||||
import { TokenIdPageClient } from "./TokenIdPage.client";
|
||||
import { TokenIdPage } from "./token-id";
|
||||
|
||||
@@ -40,6 +42,22 @@ export async function SharedNFTTokenPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// public /nfts/[tokenId] page
|
||||
if (!props.projectMeta) {
|
||||
const meta = await shouldRenderNewPublicPage(info.serverContract);
|
||||
if (meta && (meta.type === "erc721" || meta.type === "erc1155")) {
|
||||
return (
|
||||
<NFTPublicPage
|
||||
serverContract={info.serverContract}
|
||||
clientContract={info.clientContract}
|
||||
chainMetadata={info.chainMetadata}
|
||||
tokenId={props.tokenId}
|
||||
type={meta.type}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { clientContract, serverContract, isLocalhostChain } = info;
|
||||
if (isLocalhostChain) {
|
||||
return (
|
||||
|
||||
+63
-111
@@ -17,7 +17,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -34,6 +33,10 @@ import {
|
||||
} from "tw-components";
|
||||
import type { NFTMetadataInputLimited } from "types/modified-types";
|
||||
import { parseAttributes } from "utils/parseAttributes";
|
||||
import {
|
||||
getUploadedNFTMediaMeta,
|
||||
handleNFTMediaUpload,
|
||||
} from "../../modules/components/nft/handleNFTMediaUpload";
|
||||
|
||||
const LAZY_MINT_FORM_ID = "nft-lazy-mint-form";
|
||||
|
||||
@@ -54,6 +57,8 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
const address = useActiveAccount()?.address;
|
||||
const sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
|
||||
const form = useForm<NFTMetadataInputLimited>();
|
||||
|
||||
const {
|
||||
setValue,
|
||||
control,
|
||||
@@ -61,75 +66,20 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<NFTMetadataInputLimited>();
|
||||
} = form;
|
||||
|
||||
const setFile = (file: File) => {
|
||||
if (file.type.includes("image")) {
|
||||
// image files
|
||||
setValue("image", file);
|
||||
if (watch("external_url") instanceof File) {
|
||||
setValue("external_url", undefined);
|
||||
}
|
||||
if (watch("animation_url") instanceof File) {
|
||||
setValue("animation_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["audio", "video", "text/html", "model/*"].some((type: string) =>
|
||||
file.type.includes(type),
|
||||
) ||
|
||||
file.name?.endsWith(".glb") ||
|
||||
file.name?.endsWith(".usdz") ||
|
||||
file.name?.endsWith(".gltf") ||
|
||||
file.name.endsWith(".obj")
|
||||
) {
|
||||
// audio, video, html, and glb (3d) files
|
||||
setValue("animation_url", file);
|
||||
if (watch("external_url") instanceof File) {
|
||||
setValue("external_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["text", "application/pdf"].some((type: string) =>
|
||||
file.type?.includes(type),
|
||||
)
|
||||
) {
|
||||
// text and pdf files
|
||||
setValue("external_url", file);
|
||||
if (watch("animation_url") instanceof File) {
|
||||
setValue("animation_url", undefined);
|
||||
}
|
||||
}
|
||||
handleNFTMediaUpload({ file, form });
|
||||
};
|
||||
|
||||
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
|
||||
const animationUrlFormValue = watch("animation_url");
|
||||
const imageUrlFormValue = watch("image");
|
||||
|
||||
const mediaFileUrl =
|
||||
watch("animation_url") instanceof File
|
||||
? watch("animation_url")
|
||||
: watch("external_url") instanceof File
|
||||
? watch("external_url")
|
||||
: watch("image") instanceof File
|
||||
? imageUrl
|
||||
: undefined;
|
||||
|
||||
const mediaFileError =
|
||||
watch("animation_url") instanceof File
|
||||
? errors?.animation_url
|
||||
: watch("external_url") instanceof File
|
||||
? errors?.external_url
|
||||
: watch("image") instanceof File
|
||||
? errors?.image
|
||||
: undefined;
|
||||
|
||||
const externalUrl = watch("external_url");
|
||||
const externalIsTextFile =
|
||||
externalUrl instanceof File &&
|
||||
(externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
|
||||
|
||||
const showCoverImageUpload =
|
||||
watch("animation_url") instanceof File ||
|
||||
watch("external_url") instanceof File;
|
||||
const {
|
||||
media,
|
||||
image,
|
||||
mediaFileError,
|
||||
showCoverImageUpload,
|
||||
animation_url,
|
||||
external_url,
|
||||
} = getUploadedNFTMediaMeta(form);
|
||||
|
||||
const lazyMintNotifications = useTxNotifications(
|
||||
"NFT lazy minted successfully",
|
||||
@@ -184,7 +134,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="subtitle.md">Metadata</Heading>
|
||||
<Heading size="subtitle.md">Metadata </Heading>
|
||||
<Divider />
|
||||
</div>
|
||||
<FormControl isRequired isInvalid={!!errors.name}>
|
||||
@@ -197,13 +147,14 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
<div>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
value={mediaFileUrl as File | string}
|
||||
value={media}
|
||||
showUploadButton
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText="Media"
|
||||
client={contract.client}
|
||||
/>
|
||||
</div>
|
||||
<FormHelperText>
|
||||
@@ -219,8 +170,9 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
client={contract.client}
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
value={image}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="rounded border border-border transition-all"
|
||||
@@ -245,6 +197,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
control={control}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
client={contract.client}
|
||||
/>
|
||||
<Accordion
|
||||
allowToggle={!(errors.background_color || errors.external_url)}
|
||||
@@ -270,7 +223,8 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
{errors?.background_color?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{!externalIsTextFile && (
|
||||
|
||||
{!(external_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.external_url}>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
@@ -286,47 +240,45 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
{...register("image")}
|
||||
value={
|
||||
typeof imageUrlFormValue === "string"
|
||||
? imageUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded to a URL, you
|
||||
can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
{...register("animation_url")}
|
||||
value={
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media
|
||||
file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{!(image instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
{...register("image")}
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded to a URL,
|
||||
you can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
{...register("animation_url")}
|
||||
value={animation_url}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media
|
||||
file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user