Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
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@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
|
||||
# ℹ️ 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@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
|
||||
@@ -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>`
|
||||
|
||||
⸻
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
-48
@@ -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.28.1",
|
||||
"@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,16 @@
|
||||
"color": "5.0.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.1",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"fetch-event-stream": "0.1.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 +95,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"
|
||||
}
|
||||
|
||||
@@ -368,6 +368,12 @@ async function redirects() {
|
||||
destination: "/learn/guides",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect to /grant/superchain to /superchain
|
||||
{
|
||||
source: "/grant/superchain",
|
||||
destination: "/superchain",
|
||||
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;
|
||||
}
|
||||
@@ -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 { Suspense } from "react";
|
||||
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
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";
|
||||
|
||||
+7
-18
@@ -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,45 +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: { type: "completed" },
|
||||
label: "Connect Wallet",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "connect-wallet",
|
||||
},
|
||||
{
|
||||
status: { type: "pending" },
|
||||
label: "Sign Message",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "sign-message",
|
||||
},
|
||||
{
|
||||
status: { type: "error", message: "This is an error message" },
|
||||
label: "Approve Transaction",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "approve-transaction",
|
||||
},
|
||||
{
|
||||
status: { type: "idle" },
|
||||
label: "Confirm Transaction",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "confirm-transaction",
|
||||
},
|
||||
{
|
||||
status: { type: "idle" },
|
||||
label: "Finalize",
|
||||
execute: async () => {
|
||||
await sleep(1000);
|
||||
},
|
||||
id: "finalize",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,21 +10,23 @@ import {
|
||||
import { DynamicHeight } from "../../ui/DynamicHeight";
|
||||
import { Spinner } from "../../ui/Spinner/Spinner";
|
||||
|
||||
export type MultiStepState = {
|
||||
export type MultiStepState<T extends string> = {
|
||||
id: T;
|
||||
status:
|
||||
| {
|
||||
type: "idle" | "pending" | "completed";
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: string | React.ReactNode;
|
||||
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>
|
||||
@@ -55,6 +57,15 @@ export function MultiStepStatus(props: {
|
||||
{step.label}
|
||||
</p>
|
||||
|
||||
{/* 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">
|
||||
@@ -64,7 +75,7 @@ export function MultiStepStatus(props: {
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,16 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} 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";
|
||||
|
||||
+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>
|
||||
|
||||
+65
-112
@@ -16,7 +16,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 MINT_FORM_ID = "nft-mint-form";
|
||||
|
||||
@@ -52,6 +55,12 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const address = useActiveAccount()?.address;
|
||||
const form = useForm<
|
||||
NFTMetadataInputLimited & {
|
||||
supply: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const {
|
||||
setValue,
|
||||
control,
|
||||
@@ -59,78 +68,20 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<
|
||||
NFTMetadataInputLimited & {
|
||||
supply: number;
|
||||
}
|
||||
>();
|
||||
} = 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 sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
const nftMintNotifications = useTxNotifications(
|
||||
@@ -198,7 +149,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="subtitle.md">Metadata</Heading>
|
||||
<Heading size="subtitle.md">Metadata xx</Heading>
|
||||
<Divider />
|
||||
</div>
|
||||
<FormControl isRequired isInvalid={!!errors.name}>
|
||||
@@ -211,7 +162,8 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
<div>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
value={mediaFileUrl as File | string}
|
||||
client={contract.client}
|
||||
value={media}
|
||||
showUploadButton
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
@@ -232,9 +184,10 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
value={image}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="shrink-0 rounded border border-border transition-all"
|
||||
@@ -266,6 +219,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
)}
|
||||
<PropertiesFormControl
|
||||
watch={watch}
|
||||
client={contract.client}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
errors={errors as any}
|
||||
control={control}
|
||||
@@ -283,7 +237,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="flex flex-col gap-6 px-0">
|
||||
<AccordionPanel className="!px-0 flex flex-col gap-6">
|
||||
<FormControl isInvalid={!!errors.background_color}>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
@@ -296,7 +250,8 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
{errors?.background_color?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{!externalIsTextFile && (
|
||||
|
||||
{!(external_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.external_url}>
|
||||
<FormLabel>
|
||||
External URL <OpenSeaPropertyBadge />
|
||||
@@ -312,44 +267,42 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
</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 asset
|
||||
</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 asset
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{!(image instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
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 asset
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
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 asset
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
+49
-102
@@ -14,7 +14,6 @@ import {
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
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";
|
||||
@@ -31,6 +30,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 SHARED_METADATA_FORM_ID = "shared-metadata-form";
|
||||
|
||||
@@ -42,75 +45,20 @@ export const SharedMetadataForm: React.FC<{
|
||||
const trackEvent = useTrack();
|
||||
const address = useActiveAccount()?.address;
|
||||
const sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
const form = useForm<NFTMetadataInputLimited>();
|
||||
const {
|
||||
setValue,
|
||||
register,
|
||||
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 showCoverImageUpload =
|
||||
watch("animation_url") instanceof File ||
|
||||
watch("external_url") instanceof File;
|
||||
const { media, image, mediaFileError, showCoverImageUpload, animation_url } =
|
||||
getUploadedNFTMediaMeta(form);
|
||||
|
||||
const setSharedMetaNotifications = useTxNotifications(
|
||||
"Shared metadata updated successfully",
|
||||
@@ -185,7 +133,8 @@ export const SharedMetadataForm: React.FC<{
|
||||
<div>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
value={mediaFileUrl as File | string}
|
||||
value={media}
|
||||
client={contract.client}
|
||||
showUploadButton
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
@@ -206,9 +155,10 @@ export const SharedMetadataForm: React.FC<{
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
client={contract.client}
|
||||
previewMaxWidth="200px"
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
value={image}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="shrink-0 rounded border border-border transition-all"
|
||||
@@ -237,45 +187,42 @@ export const SharedMetadataForm: React.FC<{
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="flex flex-col gap-6 px-0">
|
||||
<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, you can set
|
||||
the URL or URI here.
|
||||
</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, you
|
||||
can set the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<AccordionPanel className="!px-0 flex flex-col gap-6">
|
||||
{!(image instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded, you can set
|
||||
the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{!(animation_url instanceof File) && (
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input
|
||||
value={animation_url}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded, you
|
||||
can set the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
|
||||
}
|
||||
if (isErc1155) {
|
||||
cols.push({
|
||||
Header: "Supply",
|
||||
Header: "Circulating Supply",
|
||||
accessor: (row) => row,
|
||||
Cell: (cell: CellProps<NFT, number>) => {
|
||||
if (cell.row.original.type === "ERC1155") {
|
||||
|
||||
+13
@@ -3,6 +3,7 @@ import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]
|
||||
import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { shouldRenderNewPublicPage } from "../_utils/newPublicPage";
|
||||
import { ContractNFTPage } from "./ContractNFTPage";
|
||||
import { ContractNFTPageClient } from "./ContractNFTPage.client";
|
||||
|
||||
@@ -44,6 +45,18 @@ export async function SharedNFTPage(props: {
|
||||
});
|
||||
}
|
||||
|
||||
// public nft page doesn't have /nfts page
|
||||
if (!props.projectMeta) {
|
||||
const shouldHide = await shouldRenderNewPublicPage(info.serverContract);
|
||||
if (shouldHide) {
|
||||
redirectToContractLandingPage({
|
||||
contractAddress: props.contractAddress,
|
||||
chainIdOrSlug: props.chainIdOrSlug,
|
||||
projectMeta: props.projectMeta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractNFTPage
|
||||
contract={clientContract}
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import {
|
||||
useContractTransactionAnalytics,
|
||||
useContractUniqueWalletAnalytics,
|
||||
} from "data/analytics/hooks";
|
||||
import { differenceInCalendarDays, formatDate } from "date-fns";
|
||||
import { differenceInCalendarDays, format } from "date-fns";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -200,7 +200,7 @@ export function toolTipLabelFormatterWithPrecision(precision: "day" | "hour") {
|
||||
return function toolTipLabelFormatter(_v: string, item: unknown) {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(
|
||||
return format(
|
||||
new Date(time),
|
||||
precision === "day" ? "MMM d, yyyy" : "MMM d, yyyy hh:mm a",
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { LatestEventsUI } from "./LatestEvents";
|
||||
|
||||
const meta: Meta<typeof LatestEventsUI> = {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { PermissionsTableUI } from "./PermissionsTable";
|
||||
|
||||
|
||||
+11
-3
@@ -1,15 +1,23 @@
|
||||
import { ToggleThemeButton } from "@/components/color-mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo";
|
||||
import { PublicPageConnectButton } from "./PublicPageConnectButton";
|
||||
|
||||
export function PageHeader() {
|
||||
export function PageHeader(props: {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<header className="container flex max-w-8xl justify-between py-3">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-8xl justify-between py-3",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/team" className="flex items-center gap-2">
|
||||
<ThirdwebMiniLogo className="h-6" />
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
<span className="hidden font-bold text-2xl tracking-tight lg:block">
|
||||
thirdweb
|
||||
</span>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { BadgeContainer } from "../../../../../../../../stories/utils";
|
||||
import { SupplyClaimedProgress } from "./supply-claimed-progress";
|
||||
|
||||
const meta = {
|
||||
title: "Assets/NFT/SupplyClaimedProgress",
|
||||
component: StoryVariants,
|
||||
} satisfies Meta<typeof StoryVariants>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
function StoryVariants() {
|
||||
return (
|
||||
<div className="container max-w-md space-y-10 py-10">
|
||||
<BadgeContainer label="10 / Unlimited Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={maxUint256} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="500/1000 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/10 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 Supply - don't show anything">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { InfinityIcon } from "lucide-react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { supplyFormatter } from "../nft/format";
|
||||
|
||||
export function SupplyClaimedProgress(props: {
|
||||
claimedSupply: bigint;
|
||||
totalSupply: bigint;
|
||||
}) {
|
||||
// if total supply is unlimited
|
||||
if (props.totalSupply === maxUint256) {
|
||||
return (
|
||||
<p className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">Claimed Supply </span>
|
||||
<span className="flex items-center gap-1 font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
<InfinityIcon className="size-4" aria-label="Unlimited" />
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// if total supply is 0 - don't show anything
|
||||
if (props.totalSupply === 0n) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// multiply by 10k to have precision up to 2 decimal places in percentage value
|
||||
const soldFractionTimes10KBigInt =
|
||||
(props.claimedSupply * 10000n) / props.totalSupply;
|
||||
|
||||
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Supply Claimed</span>
|
||||
<span className="font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupply)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={soldPercentage} className="h-2.5" />
|
||||
<p className="font-medium text-muted-foreground text-xs">
|
||||
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
|
||||
{soldPercentage.toFixed(2)}% Sold
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
|
||||
export function TokenPrice(props: {
|
||||
strikethrough: boolean;
|
||||
data:
|
||||
| {
|
||||
priceInTokens: number;
|
||||
symbol: string;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
return (
|
||||
<SkeletonContainer
|
||||
skeletonData={"0.00 ETH"}
|
||||
loadedData={
|
||||
props.data
|
||||
? props.data.priceInTokens === 0
|
||||
? "FREE"
|
||||
: `${tokenPriceFormatter.format(props.data.priceInTokens)} ${props.data.symbol}`
|
||||
: undefined
|
||||
}
|
||||
render={(v) => {
|
||||
if (props.strikethrough) {
|
||||
return (
|
||||
<s className="font-medium text-muted-foreground text-sm line-through decoration-muted-foreground/50">
|
||||
{v}
|
||||
</s>
|
||||
);
|
||||
}
|
||||
return <span className="font-medium text-foreground text-sm">{v}</span>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tokenPriceFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 10,
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { storybookThirdwebClient } from "stories/utils";
|
||||
import { getContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
|
||||
+5
-1
@@ -41,6 +41,7 @@ export function ContractHeaderUI(props: {
|
||||
chainMetadata: ChainMetadata;
|
||||
clientContract: ThirdwebContract;
|
||||
socialUrls: object;
|
||||
imageClassName?: string;
|
||||
}) {
|
||||
const socialUrls = useMemo(() => {
|
||||
const socialUrlsValue: { name: string; href: string }[] = [];
|
||||
@@ -67,7 +68,10 @@ export function ContractHeaderUI(props: {
|
||||
<div className="flex flex-col items-start gap-4 border-b border-dashed py-8 lg:flex-row lg:items-center">
|
||||
{props.image && (
|
||||
<Img
|
||||
className="size-20 shrink-0 rounded-full border bg-muted"
|
||||
className={cn(
|
||||
"size-20 shrink-0 rounded-full border bg-muted",
|
||||
props.imageClassName,
|
||||
)}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { differenceInCalendarDays, formatDate } from "date-fns";
|
||||
import { differenceInCalendarDays, format } from "date-fns";
|
||||
import { ArrowUpIcon, InfoIcon } from "lucide-react";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -81,7 +81,7 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
|
||||
return (_v: string, item: unknown) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(
|
||||
return format(
|
||||
new Date(time),
|
||||
includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy",
|
||||
);
|
||||
|
||||
+3
-11
@@ -18,7 +18,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { type ThirdwebClient, type ThirdwebContract, toTokens } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
@@ -171,7 +171,7 @@ function RecentTransfersUI(props: {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={props.isPending || props.data.length === 0}
|
||||
disabled={props.isPending || props.data.length < props.rowsPerPage}
|
||||
className="gap-1.5 bg-background"
|
||||
onClick={() => props.setPage(props.page + 1)}
|
||||
>
|
||||
@@ -213,7 +213,6 @@ export function RecentTransfers(props: {
|
||||
}) {
|
||||
const rowsPerPage = 10;
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasFetchedOnce, setHasFetchedOnce] = useState(false);
|
||||
|
||||
const tokenQuery = useTokenTransfers({
|
||||
chainId: props.clientContract.chain.id,
|
||||
@@ -222,18 +221,11 @@ export function RecentTransfers(props: {
|
||||
limit: rowsPerPage,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!tokenQuery.isPending) {
|
||||
setHasFetchedOnce(true);
|
||||
}
|
||||
}, [tokenQuery.isPending]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RecentTransfersUI
|
||||
data={tokenQuery.data ?? []}
|
||||
isPending={tokenQuery.isPending && !hasFetchedOnce}
|
||||
isPending={tokenQuery.isPending}
|
||||
rowsPerPage={rowsPerPage}
|
||||
client={props.clientContract.client}
|
||||
tokenMetadata={{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user