Compare commits
177
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66568467d | ||
|
|
6bd968362a | ||
|
|
17488b8139 | ||
|
|
776f5aaeda | ||
|
|
ecaa304a81 | ||
|
|
377f46c303 | ||
|
|
b52049c20a | ||
|
|
a67e980ac3 | ||
|
|
4c8333d4a3 | ||
|
|
e95ddcdd3e | ||
|
|
7973d6450c | ||
|
|
017b13f444 | ||
|
|
901c3a102b | ||
|
|
3253a532f8 | ||
|
|
0e5d120909 | ||
|
|
f368793375 | ||
|
|
b5227c9a8f | ||
|
|
0998fc737b | ||
|
|
3df0541582 | ||
|
|
2591c75b56 | ||
|
|
55edd4cb8e | ||
|
|
e0eea33d06 | ||
|
|
d7e36a8afd | ||
|
|
bcbfb2065f | ||
|
|
261102b798 | ||
|
|
42910372fc | ||
|
|
1554e377eb | ||
|
|
510be22cb6 | ||
|
|
d4430d4b2f | ||
|
|
ed58c4c930 | ||
|
|
8e22cf8812 | ||
|
|
565dbb9ef3 | ||
|
|
f01de732fb | ||
|
|
a00233d1ba | ||
|
|
acc7303c05 | ||
|
|
304f2e6b8e | ||
|
|
5585defbe4 | ||
|
|
10ca0bcd47 | ||
|
|
7b21f1b632 | ||
|
|
f7a4a466d5 | ||
|
|
895b4d145f | ||
|
|
e958e75df5 | ||
|
|
b620deecfe | ||
|
|
5218313b19 | ||
|
|
731089b29d | ||
|
|
92e55c8b3d | ||
|
|
5de5c44985 | ||
|
|
3488281958 | ||
|
|
c93755e6f6 | ||
|
|
55a00c441e | ||
|
|
761417cc1a | ||
|
|
f331f01225 | ||
|
|
7bbe60f2be | ||
|
|
9f9b67da5f | ||
|
|
24a53e3b82 | ||
|
|
9ea155e4f4 | ||
|
|
fae444da66 | ||
|
|
b30c4d0484 | ||
|
|
6b853df3d2 | ||
|
|
e841bfe081 | ||
|
|
e0c641c9cd | ||
|
|
a1fc436a92 | ||
|
|
b30177c518 | ||
|
|
040e478ad6 | ||
|
|
6771cfed62 | ||
|
|
ff188fd49d | ||
|
|
512eeb5cf0 | ||
|
|
a1707f66f3 | ||
|
|
f31801e790 | ||
|
|
ef56304195 | ||
|
|
daae1dce85 | ||
|
|
afb6343198 | ||
|
|
7d34a8f50c | ||
|
|
9584885f42 | ||
|
|
c6310e60d5 | ||
|
|
dc0ea8a286 | ||
|
|
e4b40080f4 | ||
|
|
a1b518c676 | ||
|
|
8c1fa1b7b4 | ||
|
|
453e45bd52 | ||
|
|
07af647067 | ||
|
|
535e1df9a3 | ||
|
|
a9375ce593 | ||
|
|
60eec0344d | ||
|
|
2e641f816a | ||
|
|
c8ad066cf2 | ||
|
|
0bba41446b | ||
|
|
6dda1c242e | ||
|
|
6b1f4184a4 | ||
|
|
9910a0230a | ||
|
|
9572db007a | ||
|
|
5de541878c | ||
|
|
46d0b4bdc0 | ||
|
|
1e7e32f2cb | ||
|
|
d34357e0a9 | ||
|
|
03ce8816ce | ||
|
|
2d99708c45 | ||
|
|
aa46e27d7c | ||
|
|
9f9410a77a | ||
|
|
2579525ec5 | ||
|
|
1db950e678 | ||
|
|
3a65faea2b | ||
|
|
164b70b9f4 | ||
|
|
b2adc8d671 | ||
|
|
7b88a08610 | ||
|
|
5216befe60 | ||
|
|
f81c1afeef | ||
|
|
022e403125 | ||
|
|
ea2815d33f | ||
|
|
734707e28b | ||
|
|
1d3d016e0d | ||
|
|
d831cbf5ca | ||
|
|
0ad80db089 | ||
|
|
ad77293e06 | ||
|
|
f5f5ae63b4 | ||
|
|
15358d7a0f | ||
|
|
11d287b65b | ||
|
|
562978fdc4 | ||
|
|
d31e3dabf8 | ||
|
|
882f59b24d | ||
|
|
d8e7fae9c7 | ||
|
|
a0454793c9 | ||
|
|
b633293ef1 | ||
|
|
d65387bb3d | ||
|
|
418e3e487d | ||
|
|
9fb2a713c4 | ||
|
|
87e736dacc | ||
|
|
d7f161bcd3 | ||
|
|
880c3453ee | ||
|
|
180d563139 | ||
|
|
43b7c27b34 | ||
|
|
6469fd5236 | ||
|
|
6030bf17c2 | ||
|
|
41158b17af | ||
|
|
5cc5c9327f | ||
|
|
cc47b7bca4 | ||
|
|
6b04d75bb2 | ||
|
|
93a382be92 | ||
|
|
0d23638db0 | ||
|
|
0a3b59cd9c | ||
|
|
2b730b2496 | ||
|
|
fff3c3881a | ||
|
|
75cbe64a86 | ||
|
|
f79ddf82b7 | ||
|
|
c63fefda51 | ||
|
|
f98059c426 | ||
|
|
70ed73e705 | ||
|
|
5398cb4745 | ||
|
|
2eaf7732ff | ||
|
|
c0b87503a4 | ||
|
|
58fb28de29 | ||
|
|
322e4496cf | ||
|
|
06604162a1 | ||
|
|
7d36418e0f | ||
|
|
412fb5da08 | ||
|
|
c3f240489c | ||
|
|
54840759b8 | ||
|
|
9d1f75d63a | ||
|
|
21d794c7b8 | ||
|
|
e3c0af28f7 | ||
|
|
66798886cf | ||
|
|
842ea50cbe | ||
|
|
8bd2a99a22 | ||
|
|
7a9713e877 | ||
|
|
1c6c1bf890 | ||
|
|
f81ffa9fa6 | ||
|
|
88e96dee44 | ||
|
|
ccbf2d2c22 | ||
|
|
baf21980be | ||
|
|
7a86344941 | ||
|
|
c621c13f11 | ||
|
|
004cac04aa | ||
|
|
57553b784e | ||
|
|
13fc6f5854 | ||
|
|
b03dc57072 | ||
|
|
f40d2474e6 | ||
|
|
ae217a3a60 |
@@ -8,6 +8,12 @@ playground:
|
||||
packages:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "packages/**/*"
|
||||
sdk:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "packages/thirdweb/**/*"
|
||||
portal:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "apps/portal/**/*"
|
||||
"Ecosystem Portal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "apps/wallet-ui/**/*"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 14
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- run: pnpm test
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
directory: packages/
|
||||
flags: packages
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Check for linked issue in pull request body
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
check_linear_in_pr_body:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for linked issue in pull request body
|
||||
run: |
|
||||
pr_body=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.number }} | jq -r .body)
|
||||
|
||||
echo "Pull Request Body: $pr_body"
|
||||
|
||||
if echo "$pr_body" | grep -iE "CNCT|DASH|BLOCK"; then
|
||||
echo "Linked issue found in the pull request body."
|
||||
else
|
||||
echo "No linked issue found in the pull request body."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue has been inactive for 30 days. It is now marked as stale and will be closed in 5 days if no further activity occurs.'
|
||||
stale-pr-message: 'This PR has been inactive for 30 days. It is now marked as stale and will be closed in 5 days if no further activity occurs.'
|
||||
days-before-stale: 7
|
||||
days-before-close: 2
|
||||
Vendored
+3
@@ -24,5 +24,8 @@
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,4 +91,7 @@ API_SERVER_SECRET=""
|
||||
|
||||
# Used for the Faucet page (/<chain_id>)
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
REDIS_URL=""
|
||||
|
||||
ANALYTICS_SERVICE_URL=""
|
||||
@@ -59,7 +59,6 @@ module.exports = {
|
||||
"FormHelperText",
|
||||
"FormErrorMessage",
|
||||
"MenuGroup",
|
||||
"MenuItem",
|
||||
"VStack",
|
||||
"HStack",
|
||||
"AspectRatio",
|
||||
|
||||
@@ -2,15 +2,13 @@ import type { Preview } from "@storybook/react";
|
||||
import "@/styles/globals.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { Inter as interFont } from "next/font/google";
|
||||
// biome-ignore lint/style/useImportType: <explanation>
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "../src/@/components/ui/button";
|
||||
|
||||
// Note: Wrapping the Stoy with AppRouterProviders makes the storybook server time SUPER SLOW
|
||||
// so let's just not have it there - all stories should be independent from context anyway and just rely on props
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const fontSans = interFont({
|
||||
@@ -31,9 +29,16 @@ const preview: Preview = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
return (
|
||||
<StoryLayout>
|
||||
<Story />
|
||||
</StoryLayout>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
defaultTheme="dark"
|
||||
>
|
||||
<StoryLayout>
|
||||
<Story />
|
||||
</StoryLayout>
|
||||
</ThemeProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
@@ -44,34 +49,36 @@ export default preview;
|
||||
function StoryLayout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = `font-sans antialiased ${fontSans.variable} ${theme === "dark" ? " dark" : ""}`;
|
||||
}, [theme]);
|
||||
document.body.className = `font-sans antialiased ${fontSans.variable}`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground min-w-0">
|
||||
<div className="flex justify-end p-4 border-b gap-2">
|
||||
<div className="flex min-h-screen min-w-0 flex-col bg-background text-foreground">
|
||||
<div className="flex justify-end gap-2 border-b p-4">
|
||||
<Button
|
||||
onClick={() => setTheme("dark")}
|
||||
size="sm"
|
||||
variant={theme === "dark" ? "default" : "ghost"}
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
className="h-auto w-auto rounded-full p-2"
|
||||
>
|
||||
<MoonIcon className="size-5" />
|
||||
<MoonIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setTheme("light")}
|
||||
size="sm"
|
||||
variant={theme === "light" ? "default" : "ghost"}
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
className="h-auto w-auto rounded-full p-2"
|
||||
>
|
||||
<SunIcon className="size-5" />
|
||||
<SunIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grow flex flex-col min-w-0">{props.children}</div>
|
||||
<div className="flex min-w-0 grow flex-col">{props.children}</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -23,4 +23,17 @@ Some env vars can be overridden that are required for some external services to
|
||||
|
||||
To define env vars please create a `.env` file based on the `.env.example` template at the root level of the project. This file is ignored by git so you can safely add it to your local copy of the project.
|
||||
|
||||
**Add your thirdweb clientID and secret key to build a basic functioning version of the site.**
|
||||
**Add your thirdweb clientID and secret key to build a basic functioning version of the site.**
|
||||
|
||||
## Code Structure and Style Guide
|
||||
|
||||
### Components
|
||||
- Components should go in a `components` folder next to the app page they are used in.
|
||||
- If a component is reused in multiple pages, it should go in the lowest level route folder it is used in. For example, if a chart component is used in all analytics subroutes `/analytics/...`, it should go in the `/analytics/components` folder.
|
||||
- All low-level components such as buttons, inputs, etc. should be shadcn components found in `@/ui`.
|
||||
- All composed components reused across all page routes are `blocks`, and should also go in the `@/ui` folder.
|
||||
|
||||
### Data fetching
|
||||
- Use RSC wherever possible.
|
||||
- Write data fetching code in its own function in the same file as the component it is used in, not exported.
|
||||
- If the same data fetching function is used in multiple components, place it in a file in an `api` folder at the lowest level possible (just like components). If you need to do this you probable aren't organizing your components properly.
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
// add framer paths here
|
||||
module.exports = [
|
||||
// -- home
|
||||
"/",
|
||||
"/pricing",
|
||||
// -- product landing pages --
|
||||
// -- connect
|
||||
"/connect/sign-in",
|
||||
"/nebula",
|
||||
// -- contracts
|
||||
"/contracts/modular-contracts",
|
||||
"/contracts/explore",
|
||||
"/contracts/deployment-tool",
|
||||
// -- engine
|
||||
"/engine",
|
||||
// -- solutions pages --
|
||||
"/solutions/gaming",
|
||||
"/solutions/consumer-apps",
|
||||
"/solutions/defi",
|
||||
// -- campaigns --
|
||||
"/unlimited-wallets",
|
||||
];
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import type { SentryBuildOptions } from "@sentry/nextjs";
|
||||
import type { NextConfig } from "next";
|
||||
import type { RemotePattern } from "next/dist/shared/lib/image-config";
|
||||
import FRAMER_PATHS from "./framer-rewrites";
|
||||
import getRedirects from "./redirects";
|
||||
|
||||
const ContentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
@@ -37,15 +41,9 @@ const securityHeaders = [
|
||||
},
|
||||
];
|
||||
|
||||
const redirects = require("./redirects");
|
||||
const FRAMER_PATHS = require("./framer-rewrites");
|
||||
|
||||
/**
|
||||
* @returns {import('next').RemotePattern[]}
|
||||
*/
|
||||
function determineIpfsGateways() {
|
||||
// add the clientId ipfs gateways
|
||||
const remotePatterns = [];
|
||||
const remotePatterns: RemotePattern[] = [];
|
||||
if (process.env.API_ROUTES_CLIENT_ID) {
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
@@ -84,23 +82,40 @@ function determineIpfsGateways() {
|
||||
return remotePatterns;
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const moduleExports = {
|
||||
webpack: (config, { dev }) => {
|
||||
if (config.cache && !dev) {
|
||||
config.cache = Object.freeze({
|
||||
type: "memory",
|
||||
});
|
||||
config.cache.maxMemoryGenerations = 0;
|
||||
}
|
||||
config.externals.push("pino-pretty");
|
||||
config.module = {
|
||||
...config.module,
|
||||
exprContextCritical: false,
|
||||
};
|
||||
// Important: return the modified config
|
||||
return config;
|
||||
},
|
||||
const SENTRY_OPTIONS: SentryBuildOptions = {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: "thirdweb-dev",
|
||||
project: "dashboard",
|
||||
// An auth token is required for uploading source maps.
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
// Suppresses source map uploading logs during build
|
||||
silent: true,
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||
tunnelRoute: "/err",
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors.
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: false,
|
||||
};
|
||||
|
||||
const baseNextConfig: NextConfig = {
|
||||
serverExternalPackages: ["pino-pretty"],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -117,7 +132,7 @@ const moduleExports = {
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return redirects();
|
||||
return getRedirects();
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
@@ -151,75 +166,57 @@ const moduleExports = {
|
||||
...determineIpfsGateways(),
|
||||
],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
webpackBuildWorker: true,
|
||||
serverSourceMaps: false,
|
||||
},
|
||||
cacheMaxMemorySize: 0,
|
||||
swcMinify: true,
|
||||
compiler: {
|
||||
emotion: true,
|
||||
},
|
||||
productionBrowserSourceMaps: false,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
const { withPlausibleProxy } = require("next-plausible");
|
||||
function getConfig(): NextConfig {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { withPlausibleProxy } = require("next-plausible");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
return withBundleAnalyzer(
|
||||
withPlausibleProxy({
|
||||
customDomain: "https://pl.thirdweb.com",
|
||||
scriptName: "pl",
|
||||
})(
|
||||
withSentryConfig(
|
||||
{
|
||||
...baseNextConfig,
|
||||
experimental: {
|
||||
webpackBuildWorker: true,
|
||||
},
|
||||
// @ts-expect-error - this is a valid option
|
||||
webpack: (config, { dev }) => {
|
||||
if (config.cache && !dev) {
|
||||
config.cache = Object.freeze({
|
||||
type: "filesystem",
|
||||
maxMemoryGenerations: 0,
|
||||
});
|
||||
}
|
||||
config.externals.push("pino-pretty");
|
||||
config.module = {
|
||||
...config.module,
|
||||
exprContextCritical: false,
|
||||
};
|
||||
// Important: return the modified config
|
||||
return config;
|
||||
},
|
||||
},
|
||||
SENTRY_OPTIONS,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// otherwise return the base
|
||||
return baseNextConfig;
|
||||
}
|
||||
|
||||
// we only want sentry on production environments
|
||||
const wSentry =
|
||||
process.env.NODE_ENV === "production" ? withSentryConfig : (x) => x;
|
||||
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
|
||||
module.exports = withBundleAnalyzer(
|
||||
withPlausibleProxy({
|
||||
customDomain: "https://pl.thirdweb.com",
|
||||
scriptName: "pl",
|
||||
})(
|
||||
wSentry(moduleExports, {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: "thirdweb-dev",
|
||||
project: "dashboard",
|
||||
// An auth token is required for uploading source maps.
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
// Suppresses source map uploading logs during build
|
||||
silent: true,
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Transpiles SDK to be compatible with IE11 (increases bundle size)
|
||||
transpileClientSDK: false,
|
||||
|
||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||
tunnelRoute: "/err",
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors.
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: false,
|
||||
|
||||
/**
|
||||
* Disables the Sentry Webpack plugin on the server.
|
||||
* See: https://github.com/getsentry/sentry-javascript/issues/10468#issuecomment-2004710692
|
||||
*/
|
||||
disableServerWebpackPlugin: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
export default getConfig();
|
||||
+41
-42
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --turbo",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"start": "next start",
|
||||
"format": "biome format ./src --write",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
@@ -27,9 +27,10 @@
|
||||
"@chakra-ui/theme-tools": "^2.1.2",
|
||||
"@emotion/react": "11.13.3",
|
||||
"@emotion/styled": "11.13.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.0.2",
|
||||
"@n8tb1t/use-scroll-position": "^2.0.3",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
@@ -44,14 +45,13 @@
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.3",
|
||||
"@sentry/nextjs": "8.35.0",
|
||||
"@shazow/whatsabi": "^0.15.4",
|
||||
"@stripe/react-stripe-js": "^2.8.1",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"@tanstack/react-query": "5.59.16",
|
||||
"@radix-ui/react-tooltip": "1.1.4",
|
||||
"@sentry/nextjs": "8.38.0",
|
||||
"@shazow/whatsabi": "^0.16.0",
|
||||
"@tanstack/react-query": "5.60.2",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/functions": "^1.4.2",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"abitype": "1.0.6",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
@@ -61,38 +61,37 @@
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "11.11.10",
|
||||
"framer-motion": "11.11.17",
|
||||
"fuse.js": "7.0.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "0.454.0",
|
||||
"next": "14.2.15",
|
||||
"next-plausible": "^3.12.0",
|
||||
"lucide-react": "0.456.0",
|
||||
"next": "15.0.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"next-themes": "^0.4.3",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"papaparse": "^5.4.1",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.67.1",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.3.1",
|
||||
"react": "19.0.0-rc-69d4b800-20241021",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dropzone": "^14.2.9",
|
||||
"react-dom": "19.0.0-rc-69d4b800-20241021",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-hook-form": "7.52.0",
|
||||
"react-hook-form": "7.53.2",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive-carousel": "^3.2.23",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "^2.13.0",
|
||||
"recharts": "^2.13.3",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
"shiki": "1.22.2",
|
||||
"sonner": "^1.7.0",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"swagger-ui-react": "^5.17.14",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
@@ -104,26 +103,26 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "2.0.2",
|
||||
"@next/bundle-analyzer": "14.2.15",
|
||||
"@next/eslint-plugin-next": "14.2.15",
|
||||
"@chromatic-com/storybook": "3.2.2",
|
||||
"@next/bundle-analyzer": "15.0.3",
|
||||
"@next/eslint-plugin-next": "15.0.3",
|
||||
"@playwright/test": "1.48.2",
|
||||
"@storybook/addon-essentials": "8.3.6",
|
||||
"@storybook/addon-interactions": "8.3.6",
|
||||
"@storybook/addon-links": "8.3.6",
|
||||
"@storybook/addon-onboarding": "8.3.6",
|
||||
"@storybook/addon-viewport": "8.3.6",
|
||||
"@storybook/blocks": "8.3.6",
|
||||
"@storybook/nextjs": "8.3.6",
|
||||
"@storybook/react": "8.3.6",
|
||||
"@storybook/test": "8.3.6",
|
||||
"@storybook/addon-essentials": "8.4.4",
|
||||
"@storybook/addon-interactions": "8.4.4",
|
||||
"@storybook/addon-links": "8.4.4",
|
||||
"@storybook/addon-onboarding": "8.4.4",
|
||||
"@storybook/addon-viewport": "8.4.4",
|
||||
"@storybook/blocks": "8.4.4",
|
||||
"@storybook/nextjs": "8.4.4",
|
||||
"@storybook/react": "8.4.4",
|
||||
"@storybook/test": "8.4.4",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "npm:[email protected].1",
|
||||
"@types/react-dom": "npm:[email protected].1",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
@@ -133,13 +132,13 @@
|
||||
"checkly": "^4.8.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.3",
|
||||
"eslint-plugin-react-compiler": "0.0.0-experimental-fa06e2c-20241014",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
|
||||
"eslint-plugin-storybook": "^0.9.0",
|
||||
"knip": "5.34.2",
|
||||
"knip": "5.37.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.4.47",
|
||||
"storybook": "8.3.6",
|
||||
"tailwindcss": "3.4.14",
|
||||
"postcss": "8.4.49",
|
||||
"storybook": "8.4.4",
|
||||
"tailwindcss": "3.4.15",
|
||||
"typescript": "5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12.813" y="18.824" width="13.4583" height="9.33069" rx="1.49536" transform="rotate(-80 12.813 18.824)" fill="url(#paint0_linear_176_19664)"/>
|
||||
<g filter="url(#filter0_bi_176_19664)">
|
||||
<path d="M8.63443 22.4473C9.10456 22.866 9.75478 22.9806 10.3397 22.748C12.1658 22.0219 16.0712 20.0922 16.6714 16.6883L17.3498 12.8408C17.4946 12.0198 17.1149 11.1951 16.397 10.7712L12.6731 8.57281C12.2567 8.32698 11.7653 8.24034 11.29 8.32893L7.0388 9.12114C6.21922 9.27387 5.58034 9.91897 5.43557 10.74L4.75715 14.5875C4.15696 17.9914 7.1668 21.1405 8.63443 22.4473Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M10.6767 15.8535C11.6031 16.0169 12.4866 15.3983 12.6499 14.4719C12.8133 13.5454 12.1947 12.662 11.2683 12.4986C10.3418 12.3353 9.45839 12.9539 9.29504 13.8803C9.13169 14.8067 9.75028 15.6902 10.6767 15.8535Z" fill="white"/>
|
||||
<path d="M10.8049 15.1276C11.13 15.1849 11.3459 15.5036 11.2867 15.8396L10.8578 18.2722C10.7986 18.6079 10.4869 18.8339 10.1615 18.7765C9.83634 18.7192 9.6204 18.4005 9.67966 18.0644L10.1086 15.6319C10.1678 15.2962 10.4794 15.0702 10.8049 15.1276Z" fill="white"/>
|
||||
<defs>
|
||||
<filter id="filter0_bi_176_19664" x="2.6792" y="6.29507" width="16.7012" height="18.5715" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19664"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19664" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19664"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19664" x1="23.6039" y1="22.7328" x2="13.5455" y2="27.1317" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19666)">
|
||||
<path d="M10.2684 4.78857C11.0382 3.45524 12.9627 3.45524 13.7325 4.78857L20.7761 16.9885C21.5459 18.3218 20.5837 19.9885 19.0441 19.9885H4.95684C3.41724 19.9885 2.45499 18.3218 3.22479 16.9885L10.2684 4.78857Z" fill="url(#paint0_linear_176_19666)"/>
|
||||
<g filter="url(#filter0_i_176_19666)">
|
||||
<path d="M11.1341 13.3799C11.519 12.7132 12.4812 12.7132 12.8661 13.3799L15.811 18.4806C16.1959 19.1473 15.7148 19.9806 14.945 19.9806H9.05519C8.28539 19.9806 7.80426 19.1473 8.18916 18.4806L11.1341 13.3799Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_i_176_19666" x="8.05371" y="12.8799" width="7.89258" height="7.20071" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_176_19666"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19666" x1="3.35247" y1="20.8199" x2="21.8623" y2="12.4791" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19666">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19660)">
|
||||
<circle cx="18.6199" cy="17.2472" r="5" transform="rotate(-10 18.6199 17.2472)" fill="url(#paint0_linear_176_19660)"/>
|
||||
<g filter="url(#filter0_bi_176_19660)">
|
||||
<path d="M3.83469 8.17681C3.69084 7.36097 4.23559 6.58298 5.05143 6.43913L17.8539 4.1817C18.6698 4.03785 19.4478 4.5826 19.5916 5.39844L21.1544 14.2617C21.2983 15.0776 20.7536 15.8555 19.9377 15.9994L7.13521 18.2568C6.31937 18.4007 5.54138 17.8559 5.39752 17.0401L3.83469 8.17681Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<rect x="6.65332" y="12.7048" width="3.54886" height="3.54886" rx="1" transform="rotate(-10 6.65332 12.7048)" fill="white"/>
|
||||
<path d="M4.09668 9.64545L19.8505 6.86762" stroke="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_bi_176_19660" x="1.81152" y="2.15866" width="21.3657" height="18.1212" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19660"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19660" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19660"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19660" x1="21.6379" y1="16.4364" x2="13.4844" y2="18.9086" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19660">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.0986328 4.26613) rotate(-10)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19662)">
|
||||
<rect x="12.7549" y="5.24113" width="13.4583" height="9.33069" rx="1.49536" transform="rotate(20 12.7549 5.24113)" fill="url(#paint0_linear_176_19662)"/>
|
||||
<g filter="url(#filter0_bi_176_19662)">
|
||||
<path d="M8.56889 10.1027C8.94667 9.06477 10.0944 8.5296 11.1323 8.90738L24.288 13.6957C25.326 14.0735 25.8611 15.2211 25.4834 16.2591L22.0632 25.656C21.6854 26.694 20.5377 27.2291 19.4997 26.8514L6.34403 22.0631C5.30608 21.6853 4.7709 20.5376 5.14869 19.4997L8.56889 10.1027Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M11.0957 12.0434L15.3474 18.4228L22.4439 16.1739" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_bi_176_19662" x="3.02734" y="6.78622" width="24.5771" height="22.1863" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19662"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19662" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19662"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19662" x1="23.5457" y1="9.14994" x2="13.4874" y2="13.5488" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19662">
|
||||
<rect width="24" height="24" fill="white" transform="translate(8.82812 0.619431) rotate(20)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19658)">
|
||||
<rect x="24.7207" y="12.0584" width="16" height="10" rx="1.5" transform="rotate(132.102 24.7207 12.0584)" fill="url(#paint0_linear_176_19658)"/>
|
||||
<g filter="url(#filter0_i_176_19658)">
|
||||
<path d="M7.17965 18.0792C6.80186 17.0413 7.33703 15.8936 8.37499 15.5158L19.6513 11.4116C20.6893 11.0338 21.8369 11.569 22.2147 12.6069L24.9509 20.1245C25.3287 21.1624 24.7935 22.3101 23.7555 22.6879L12.4792 26.7921C11.4413 27.1699 10.2936 26.6347 9.91581 25.5968L7.17965 18.0792Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M9.40967 18.3986L12.344 17.3306C12.8956 17.1298 13.5112 17.2202 13.9819 17.5709V17.5709C14.9874 18.3203 16.4315 17.7947 16.72 16.5744V16.5744C16.8551 16.0031 17.2686 15.5382 17.8202 15.3374L20.712 14.2849" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_i_176_19658" x="7.05859" y="11.2904" width="18.0137" height="15.7229" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_176_19658"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19658" x1="37.5495" y1="16.2476" x2="26.0184" y2="21.8418" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19658">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.619629 8.82776) rotate(-20)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
+106
-124
@@ -1,5 +1,69 @@
|
||||
const legacyDashboardToTeamRedirects = [
|
||||
{
|
||||
source: "/dashboard",
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/contracts/:path*",
|
||||
destination: "/team/~/~/contracts",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/connect/ecosystem/:path*",
|
||||
destination: "/team/~/~/ecosystem/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/engine/:path*",
|
||||
destination: "/team/~/~/engine/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/api-keys",
|
||||
destination: "/team/~/~/projects",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/devices",
|
||||
destination: "/account/devices",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/billing",
|
||||
destination: "/team/~/~/settings/billing",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/gas-credits",
|
||||
destination: "/team/~/~/settings/credits",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/usage",
|
||||
destination: "/team/~/~/usage",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/storage",
|
||||
destination: "/team/~/~/usage/storage",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/notifications",
|
||||
destination: "/team/~/~/settings/notifications",
|
||||
permanent: false,
|
||||
},
|
||||
// rest of the /dashboard/* routes
|
||||
{
|
||||
source: "/dashboard/:path*",
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {import('next').NextConfig['redirects']} */
|
||||
function redirects() {
|
||||
async function redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/portal/:match*",
|
||||
@@ -11,16 +75,6 @@ function redirects() {
|
||||
destination: "/solutions/chains",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/publish/:path*",
|
||||
destination: "/contracts/publish/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/mumbai/publish/:path*",
|
||||
destination: "/contracts/publish/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/contracts/release",
|
||||
destination: "/contracts/publish",
|
||||
@@ -97,23 +151,8 @@ function redirects() {
|
||||
destination: "/polygon-zkevm",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/settings",
|
||||
destination: "/dashboard/settings/api-keys",
|
||||
permanent: false,
|
||||
},
|
||||
// backwards compat: page moved to pages/settings/api-key
|
||||
{
|
||||
source: "/dashboard/api-keys",
|
||||
destination: "/dashboard/settings/api-keys",
|
||||
permanent: false,
|
||||
},
|
||||
// backwards compat: page moved to pages/settings/devices
|
||||
{
|
||||
source: "/dashboard/settings/account",
|
||||
destination: "/dashboard/settings/devices",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
{
|
||||
source: "/template/nft-drop",
|
||||
destination: "/template/erc721",
|
||||
@@ -121,17 +160,12 @@ function redirects() {
|
||||
},
|
||||
{
|
||||
source: "/create-api-key",
|
||||
destination: "/dashboard/settings/api-keys",
|
||||
destination: "/team/~/~/projects",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings",
|
||||
destination: "/dashboard/settings/api-keys",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/connect",
|
||||
destination: "/dashboard/connect/analytics",
|
||||
destination: "/team/~/~/projects",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
@@ -139,61 +173,6 @@ function redirects() {
|
||||
destination: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/wallet",
|
||||
destination: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/rpc",
|
||||
destination: "/dashboard/infrastructure/rpc-edge",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/storage",
|
||||
destination: "/dashboard/infrastructure/storage",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/smart-wallet",
|
||||
destination: "/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/published",
|
||||
destination: "/dashboard/publish",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/wallets",
|
||||
destination: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/wallets/analytics",
|
||||
destination: "/dashboard/connect/analytics",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/wallets/embedded",
|
||||
destination: "/dashboard/connect/embedded-wallets",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/wallets/smart-wallet",
|
||||
destination: "/dashboard/connect/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/wallets/connect",
|
||||
destination: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/infrastructure",
|
||||
destination: "/dashboard/settings/storage",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/infrastructure/storage",
|
||||
destination: "/dashboard/settings/storage",
|
||||
@@ -204,26 +183,6 @@ function redirects() {
|
||||
destination: "/chainlist",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/contracts",
|
||||
destination: "/dashboard/contracts/deploy",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/solidity-sdk",
|
||||
destination: "/build",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/connect-wallet",
|
||||
destination: "/connect",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/wallet-sdk",
|
||||
destination: "https://portal.thirdweb.com/wallet-sdk/latest",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/solutions/commerce",
|
||||
destination: "/solutions/loyalty",
|
||||
@@ -244,27 +203,12 @@ function redirects() {
|
||||
destination: "/optimism",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/payments/settings",
|
||||
destination: "/dashboard/connect/pay",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/payments/contracts",
|
||||
destination: "/dashboard/connect/pay",
|
||||
permanent: false,
|
||||
},
|
||||
// Redirecting as ambassadors lives in community now
|
||||
{
|
||||
source: "/ambassadors",
|
||||
destination: "/community/ambassadors",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/connect/embedded-wallets",
|
||||
destination: "/dashboard/connect/in-app-wallets",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/embedded-wallets",
|
||||
destination: "/in-app-wallets",
|
||||
@@ -284,6 +228,44 @@ function redirects() {
|
||||
destination: "/explore",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
// framer redirects
|
||||
{
|
||||
source: "/deploy",
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/publish",
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/smart-contracts",
|
||||
destination: "/contracts/explore",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/build",
|
||||
destination: "/contracts/modular-contracts",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/ui-components",
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/interact",
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/sponsored-transactions",
|
||||
destination: "/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { API_SERVER_URL, getAbsoluteUrlFromPath } from "@/constants/env";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
|
||||
export type RedirectCheckoutOptions = {
|
||||
teamSlug: string;
|
||||
sku: ProductSKU;
|
||||
redirectPath?: string;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
export async function redirectToCheckout(
|
||||
options: RedirectCheckoutOptions,
|
||||
): Promise<{ status: number }> {
|
||||
if (!options.teamSlug) {
|
||||
return {
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-link`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sku: options.sku,
|
||||
redirectTo: getAbsoluteUrlFromPath(
|
||||
options.redirectPath ||
|
||||
`/team/${options.teamSlug}/~/settings/billing`,
|
||||
).toString(),
|
||||
metadata: options.metadata || {},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.result) {
|
||||
return {
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
|
||||
// redirect to the stripe checkout session
|
||||
redirect(json.result);
|
||||
}
|
||||
|
||||
export type BillingPortalOptions = {
|
||||
teamSlug: string | undefined;
|
||||
redirectPath?: string;
|
||||
};
|
||||
export async function redirectToBillingPortal(
|
||||
options: BillingPortalOptions,
|
||||
): Promise<{ status: number }> {
|
||||
if (!options.teamSlug) {
|
||||
return {
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return {
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-session-link`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
redirectTo: getAbsoluteUrlFromPath(
|
||||
options.redirectPath ||
|
||||
`/team/${options.teamSlug}/~/settings/billing`,
|
||||
).toString(),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.result) {
|
||||
return {
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
|
||||
// redirect to the stripe billing portal
|
||||
redirect(json.result);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function confirmEmailWithOTP(otp: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/account/confirmEmail`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirmationToken: otp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
return {
|
||||
errorMessage: json.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage: "Failed to confirm email",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getRawAccount } from "../../app/account/settings/getAccount";
|
||||
|
||||
export async function getRawAccountAction() {
|
||||
return getRawAccount();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function joinTeamWaitlist(options: {
|
||||
teamSlug: string;
|
||||
// currently only 'nebula' is supported
|
||||
scope: "nebula";
|
||||
}) {
|
||||
const { teamSlug, scope } = options;
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scope,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
errorMessage: "Failed to join waitlist",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function updateAccount(values: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No Auth token");
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/account`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
return {
|
||||
errorMessage: json.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage: "Failed To Update Account",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { fetchAnalytics } from "data/analytics/fetch-analytics";
|
||||
|
||||
export interface WalletStats {
|
||||
date: string;
|
||||
uniqueWalletsConnected: number;
|
||||
totalConnections: number;
|
||||
walletType: string;
|
||||
}
|
||||
|
||||
export interface WalletUserStats {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
returningUsers: number;
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
export interface InAppWalletStats {
|
||||
date: string;
|
||||
authenticationMethod: string;
|
||||
uniqueWalletsConnected: number;
|
||||
}
|
||||
|
||||
export interface EcosystemWalletStats extends InAppWalletStats {}
|
||||
|
||||
export interface UserOpStats {
|
||||
date: string;
|
||||
successful: number;
|
||||
failed: number;
|
||||
sponsoredUsd: number;
|
||||
chainId?: string;
|
||||
}
|
||||
|
||||
interface AnalyticsQueryParams {
|
||||
clientId?: string;
|
||||
accountId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}
|
||||
|
||||
function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.clientId) {
|
||||
searchParams.append("clientId", params.clientId);
|
||||
}
|
||||
if (params.accountId) {
|
||||
searchParams.append("accountId", params.accountId);
|
||||
}
|
||||
if (params.from) {
|
||||
searchParams.append("from", params.from.toISOString());
|
||||
}
|
||||
if (params.to) {
|
||||
searchParams.append("to", params.to.toISOString());
|
||||
}
|
||||
if (params.period) {
|
||||
searchParams.append("period", params.period);
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export async function getWalletConnections(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<WalletStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/wallets?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch wallet connections");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as WalletStats[];
|
||||
}
|
||||
|
||||
export async function getInAppWalletUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<InAppWalletStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/wallets/in-app?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch in-app wallet usage");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as InAppWalletStats[];
|
||||
}
|
||||
|
||||
export async function getUserOpUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<UserOpStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/user-ops?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch user ops usage");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as UserOpStats[];
|
||||
}
|
||||
|
||||
export async function getWalletUsers(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<WalletUserStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/wallets/users?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch wallet user stats");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as WalletUserStats[];
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export type Project = {
|
||||
};
|
||||
|
||||
export async function getProjects(teamSlug: string) {
|
||||
const cookiesManager = cookies();
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
@@ -46,7 +46,7 @@ export async function getProjects(teamSlug: string) {
|
||||
}
|
||||
|
||||
export async function getProject(teamSlug: string, projectSlug: string) {
|
||||
const cookiesManager = cookies();
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
|
||||
@@ -25,7 +25,7 @@ export type TeamMember = {
|
||||
};
|
||||
|
||||
export async function getMembers(teamSlug: string) {
|
||||
const token = getAuthToken();
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
|
||||
type InvoiceLine = {
|
||||
// amount for this line item
|
||||
amount: number;
|
||||
// statement descriptor
|
||||
description: string | null;
|
||||
// the thirdweb product sku or null if it is not recognized
|
||||
thirdwebSku: ProductSKU | null;
|
||||
};
|
||||
|
||||
type Invoice = {
|
||||
// total amount excluding tax
|
||||
amount: number | null;
|
||||
// the ISO currency code (e.g. USD)
|
||||
currency: string;
|
||||
// the line items on the invoice
|
||||
lines: InvoiceLine[];
|
||||
};
|
||||
|
||||
export type TeamSubscription = {
|
||||
id: string;
|
||||
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
|
||||
status:
|
||||
| "incomplete"
|
||||
| "incomplete_expired"
|
||||
| "trialing"
|
||||
| "active"
|
||||
| "past_due"
|
||||
| "canceled"
|
||||
| "unpaid"
|
||||
| "paused";
|
||||
currentPeriodStart: string;
|
||||
currentPeriodEnd: string;
|
||||
trialStart: string | null;
|
||||
trialEnd: string | null;
|
||||
upcomingInvoice: Invoice;
|
||||
};
|
||||
|
||||
export async function getTeamSubscriptions(slug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const teamRes = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${slug}/subscriptions`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (teamRes.ok) {
|
||||
return (await teamRes.json())?.result as TeamSubscription[];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import "server-only";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
export type Team = {
|
||||
id: string;
|
||||
@@ -12,22 +11,14 @@ export type Team = {
|
||||
deletedAt?: string;
|
||||
bannedAt?: string;
|
||||
image?: string;
|
||||
billingPlan: "pro" | "growth" | "free";
|
||||
billingStatus: "validPayment" | (string & {}); // what's the other value?
|
||||
billingEmail: string;
|
||||
// billingExternalId: string;
|
||||
// billingType: "STRIPE" | ??
|
||||
// billingCustomerPayload: ?? | null
|
||||
// paymentAttemptCount: number
|
||||
// trialPeriodEndedAt: string | null;
|
||||
billingPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
billingEmail: string | null;
|
||||
growthTrialEligible: boolean | null;
|
||||
};
|
||||
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
const cookiesManager = cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -45,14 +36,9 @@ export async function getTeamBySlug(slug: string) {
|
||||
}
|
||||
|
||||
export async function getTeams() {
|
||||
const cookiesManager = cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
const teamsRes = await fetch(`${API_SERVER_URL}/v1/teams`, {
|
||||
@@ -63,5 +49,33 @@ export async function getTeams() {
|
||||
if (teamsRes.ok) {
|
||||
return (await teamsRes.json())?.result as Team[];
|
||||
}
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
type TeamNebulWaitList = {
|
||||
onWaitlist: boolean;
|
||||
createdAt: null | string;
|
||||
};
|
||||
|
||||
export async function getTeamNebulaWaitList(teamSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist?scope=nebula`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
return (await res.json()).result as TeamNebulWaitList;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { setCookie } from "../../stores/SyncStoreToCookies";
|
||||
|
||||
export function DashboardTypeCookieSetter(props: {
|
||||
type: "team" | "old";
|
||||
}) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
setCookie("x-dashboard-type", props.type);
|
||||
}, [props.type]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type BillingPortalOptions,
|
||||
type RedirectCheckoutOptions,
|
||||
redirectToBillingPortal,
|
||||
redirectToCheckout,
|
||||
} from "../actions/billing";
|
||||
import { Button, type ButtonProps } from "./ui/button";
|
||||
|
||||
type CheckoutButtonProps = RedirectCheckoutOptions & ButtonProps;
|
||||
export function CheckoutButton({
|
||||
onClick,
|
||||
teamSlug,
|
||||
sku,
|
||||
metadata,
|
||||
redirectPath,
|
||||
children,
|
||||
...restProps
|
||||
}: CheckoutButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
{...restProps}
|
||||
onClick={async (e) => {
|
||||
onClick?.(e);
|
||||
await redirectToCheckout({
|
||||
teamSlug,
|
||||
sku,
|
||||
metadata,
|
||||
redirectPath,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type BillingPortalButtonProps = BillingPortalOptions & ButtonProps;
|
||||
export function BillingPortalButton({
|
||||
onClick,
|
||||
teamSlug,
|
||||
redirectPath,
|
||||
children,
|
||||
...restProps
|
||||
}: BillingPortalButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
{...restProps}
|
||||
onClick={async (e) => {
|
||||
onClick?.(e);
|
||||
await redirectToBillingPortal({
|
||||
teamSlug,
|
||||
redirectPath,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "../../../../stories/utils";
|
||||
import { getThirdwebClient } from "../../../constants/thirdweb.server";
|
||||
import { Button } from "../../ui/button";
|
||||
import { GradientAvatar } from "./GradientAvatar";
|
||||
|
||||
@@ -17,29 +18,74 @@ export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
const client = getThirdwebClient();
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 p-10">
|
||||
<p> All images below are set with size-6 className </p>
|
||||
|
||||
<BadgeContainer label="No Src, No id - Skeleton">
|
||||
<GradientAvatar id={undefined} src={undefined} className="size-6" />
|
||||
<GradientAvatar
|
||||
id={undefined}
|
||||
src={undefined}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Src, id=foo - Skeleton">
|
||||
<GradientAvatar id={"foo"} src={undefined} className="size-6" />
|
||||
<GradientAvatar
|
||||
id={"foo"}
|
||||
src={undefined}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Invalid/Empty Src, id=foo - Gradient">
|
||||
<GradientAvatar id={"foo"} src={""} className="size-6" />
|
||||
<BadgeContainer label="Invalid Src, id=foo - Gradient">
|
||||
<GradientAvatar
|
||||
id={"foo"}
|
||||
src={""}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Invalid/Empty Src, id=bar - Gradient">
|
||||
<GradientAvatar id={"bar"} src={""} className="size-6" />
|
||||
<BadgeContainer label="Invalid Src, id=bar - Gradient">
|
||||
<GradientAvatar
|
||||
id={"bar"}
|
||||
src={""}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Empty/Invalid Src, No id - default fallback">
|
||||
<GradientAvatar src="invalid-src" id={undefined} className="size-6" />
|
||||
<GradientAvatar
|
||||
src="invalid-src"
|
||||
id={undefined}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Valid Src, https">
|
||||
<GradientAvatar
|
||||
src="https://picsum.photos/200/300"
|
||||
id={undefined}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Valid Src, ipfs">
|
||||
<GradientAvatar
|
||||
src="ipfs://QmZbeJYEs7kCJHyQxjxU2SJUtjSAr4m87wzJFJUyWomKdj/Smily.svg"
|
||||
id={undefined}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<ToggleTest />
|
||||
@@ -74,7 +120,12 @@ function ToggleTest() {
|
||||
<p> Src+ID is: {data ? "set" : "not set"} </p>
|
||||
|
||||
<BadgeContainer label="Valid Src">
|
||||
<GradientAvatar src={data?.src} id={data?.id} className="size-6" />
|
||||
<GradientAvatar
|
||||
src={data?.src}
|
||||
id={data?.id}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="invalid Src">
|
||||
@@ -82,6 +133,7 @@ function ToggleTest() {
|
||||
className="size-6"
|
||||
src={data ? "invalid-src" : undefined}
|
||||
id={undefined}
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
const gradients = [
|
||||
@@ -32,6 +34,7 @@ export function GradientAvatar(props: {
|
||||
src: string | undefined;
|
||||
id: string | undefined;
|
||||
className: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const gradient = useMemo(() => {
|
||||
if (!props.id) {
|
||||
@@ -40,9 +43,16 @@ export function GradientAvatar(props: {
|
||||
return getGradientForString(props.id);
|
||||
}, [props.id]);
|
||||
|
||||
const resolvedSrc = props.src
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: props.src,
|
||||
})
|
||||
: props.src;
|
||||
|
||||
return (
|
||||
<Img
|
||||
src={props.src}
|
||||
src={resolvedSrc}
|
||||
className={cn("rounded-full", props.className)}
|
||||
fallback={
|
||||
gradient ? (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { DangerSettingCard } from "./DangerSettingCard";
|
||||
|
||||
const meta = {
|
||||
title: "blocks/DangerSettingCard",
|
||||
title: "blocks/Cards/DangerSettingCard",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export function FormFieldSetup(props: {
|
||||
children: React.ReactNode;
|
||||
tooltip?: React.ReactNode;
|
||||
isRequired: boolean;
|
||||
helperText?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@@ -26,8 +27,13 @@ export function FormFieldSetup(props: {
|
||||
)}
|
||||
</div>
|
||||
{props.children}
|
||||
|
||||
{props.helperText && (
|
||||
<p className="mt-2 text-muted-foreground text-sm">{props.helperText}</p>
|
||||
)}
|
||||
|
||||
{props.errorMessage && (
|
||||
<p className="mt-1 text-destructive-text text-sm">
|
||||
<p className="mt-2 text-destructive-text text-sm">
|
||||
{props.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { SettingsCard } from "./SettingsCard";
|
||||
|
||||
const meta = {
|
||||
title: "blocks/SettingsCard",
|
||||
title: "Blocks/Cards/SettingsCard",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
|
||||
@@ -51,7 +51,8 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
|
||||
{props.links.map((link, i) => {
|
||||
if ("group" in link) {
|
||||
return (
|
||||
<div className={cn({ "mt-6": i !== 0 })}>
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: items won't be reordered
|
||||
<div className={cn({ "mt-6": i !== 0 })} key={i}>
|
||||
<p className={cn("px-3 py-2 font-medium text-foreground")}>
|
||||
{link.group}
|
||||
</p>
|
||||
@@ -63,7 +64,8 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
|
||||
const isExternal = link.href.startsWith("http");
|
||||
return (
|
||||
<NavLink
|
||||
key={link.href}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: items won't be reordered
|
||||
key={i}
|
||||
href={link.href}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-muted-foreground text-sm hover:bg-muted"
|
||||
activeClassName="text-foreground"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import type React from "react";
|
||||
import { type Dispatch, type SetStateAction, useMemo } from "react";
|
||||
import { TabButtons } from "../ui/tabs";
|
||||
|
||||
export type CodeEnvironment =
|
||||
| "javascript"
|
||||
| "typescript"
|
||||
| "react"
|
||||
| "react-native"
|
||||
| "unity";
|
||||
|
||||
type SupportedEnvironment = {
|
||||
environment: CodeEnvironment;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type CodeSnippet = Partial<Record<CodeEnvironment, string>>;
|
||||
|
||||
const Environments: SupportedEnvironment[] = [
|
||||
{
|
||||
environment: "javascript",
|
||||
title: "JavaScript",
|
||||
},
|
||||
{
|
||||
environment: "typescript",
|
||||
title: "TypeScript",
|
||||
},
|
||||
{
|
||||
environment: "react",
|
||||
title: "React",
|
||||
},
|
||||
{
|
||||
environment: "react-native",
|
||||
title: "React Native",
|
||||
},
|
||||
{
|
||||
environment: "unity",
|
||||
title: "Unity",
|
||||
},
|
||||
];
|
||||
|
||||
interface CodeSegmentProps {
|
||||
snippet: CodeSnippet;
|
||||
environment: CodeEnvironment;
|
||||
setEnvironment:
|
||||
| Dispatch<SetStateAction<CodeEnvironment>>
|
||||
| ((language: CodeEnvironment) => void);
|
||||
isInstallCommand?: boolean;
|
||||
hideTabs?: boolean;
|
||||
onlyTabs?: boolean;
|
||||
}
|
||||
|
||||
export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
snippet,
|
||||
environment,
|
||||
setEnvironment,
|
||||
isInstallCommand,
|
||||
hideTabs,
|
||||
onlyTabs,
|
||||
}) => {
|
||||
const activeEnvironment: CodeEnvironment = useMemo(() => {
|
||||
return (
|
||||
snippet[environment] ? environment : Object.keys(snippet)[0]
|
||||
) as CodeEnvironment;
|
||||
}, [environment, snippet]);
|
||||
|
||||
const activeSnippet = useMemo(() => {
|
||||
return snippet[activeEnvironment];
|
||||
}, [activeEnvironment, snippet]);
|
||||
|
||||
const lines = useMemo(
|
||||
() => (activeSnippet ? activeSnippet.split("\n") : []),
|
||||
[activeSnippet],
|
||||
);
|
||||
|
||||
const code = lines.join("\n").trim();
|
||||
|
||||
const environments = Environments.filter(
|
||||
(env) =>
|
||||
Object.keys(snippet).includes(env.environment) &&
|
||||
snippet[env.environment],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col overflow-hidden rounded-lg border border-border"
|
||||
}
|
||||
>
|
||||
{!hideTabs && (
|
||||
<TabButtons
|
||||
tabs={environments.map((env) => ({
|
||||
label: env.title,
|
||||
onClick: () => setEnvironment(env.environment),
|
||||
isActive: activeEnvironment === env.environment,
|
||||
name: env.title,
|
||||
isEnabled: true,
|
||||
}))}
|
||||
tabClassName="text-sm gap-2 !text-sm"
|
||||
tabIconClassName="size-4"
|
||||
tabContainerClassName="px-3 pt-1.5 gap-0.5"
|
||||
hideBottomLine={!!onlyTabs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{onlyTabs ? null : (
|
||||
<>
|
||||
<CodeClient
|
||||
code={code}
|
||||
loadingClassName="min-h-[450px] rounded-none border-none"
|
||||
className="rounded-none border-none"
|
||||
lang={
|
||||
isInstallCommand
|
||||
? activeEnvironment === "react-native"
|
||||
? "tsx"
|
||||
: "bash"
|
||||
: activeEnvironment === "react" ||
|
||||
activeEnvironment === "react-native"
|
||||
? "tsx"
|
||||
: activeEnvironment === "unity"
|
||||
? "cpp"
|
||||
: activeEnvironment
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { type CodeEnvironment, CodeSegment } from "./code-segment.client";
|
||||
|
||||
const meta = {
|
||||
title: "blocks/CodeSegment",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
type Mode = "default" | "no-tabs" | "only-tabs";
|
||||
|
||||
function Story() {
|
||||
const [mode, setMode] = useState<Mode>("default");
|
||||
const modes: Mode[] = ["default", "no-tabs", "only-tabs"];
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-[700px] flex-col gap-10 py-10">
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(v) => {
|
||||
setMode(v as Mode);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{modes.map((item) => {
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Variant envsToShow={["javascript"]} storyLabel="1 env" mode={mode} />
|
||||
|
||||
<Variant
|
||||
envsToShow={["javascript", "typescript"]}
|
||||
storyLabel="2 envs"
|
||||
mode={mode}
|
||||
/>
|
||||
|
||||
<Variant
|
||||
envsToShow={[
|
||||
"javascript",
|
||||
"typescript",
|
||||
"react",
|
||||
"react-native",
|
||||
"unity",
|
||||
]}
|
||||
storyLabel="5 envs"
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Variant(props: {
|
||||
envsToShow: CodeEnvironment[];
|
||||
storyLabel: string;
|
||||
mode: Mode;
|
||||
}) {
|
||||
const [env, setEnv] = useState<CodeEnvironment>(
|
||||
props.envsToShow[0] || "javascript",
|
||||
);
|
||||
return (
|
||||
<BadgeContainer label={props.storyLabel}>
|
||||
<CodeSegment
|
||||
snippet={{
|
||||
javascript: props.envsToShow.includes("javascript")
|
||||
? jsCode
|
||||
: undefined,
|
||||
react: props.envsToShow.includes("react") ? jsxCode : undefined,
|
||||
unity: props.envsToShow.includes("unity") ? unityCode : undefined,
|
||||
"react-native": props.envsToShow.includes("react-native")
|
||||
? jsxCode
|
||||
: undefined,
|
||||
typescript: props.envsToShow.includes("typescript")
|
||||
? tsCode
|
||||
: undefined,
|
||||
}}
|
||||
environment={env}
|
||||
setEnvironment={setEnv}
|
||||
hideTabs={props.mode === "no-tabs"}
|
||||
onlyTabs={props.mode === "only-tabs"}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const jsCode = `\
|
||||
import { getContract } from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { getOwnedNFTs } from "thirdweb/extensions/erc1155";
|
||||
|
||||
const contract = getContract({
|
||||
client,
|
||||
address: "0x1234...",
|
||||
chain: sepolia,
|
||||
});
|
||||
`;
|
||||
|
||||
const jsxCode = `\
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
|
||||
function Main() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<App />
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}`;
|
||||
|
||||
const unityCode = `\
|
||||
using Thirdweb;
|
||||
|
||||
// Reference the SDK
|
||||
var sdk = ThirdwebManager.Instance.SDK;
|
||||
|
||||
// Create data
|
||||
NFTMetadata meta = new NFTMetadata()
|
||||
{
|
||||
name = "Unity NFT",
|
||||
description = "Minted From Unity",
|
||||
image = "ipfs://QmbpciV7R5SSPb6aT9kEBAxoYoXBUsStJkMpxzymV4ZcVc",
|
||||
};
|
||||
string metaJson = Newtonsoft.Json.JsonConvert.SerializeObject(meta);
|
||||
|
||||
// Upload raw text or from a file path
|
||||
var response = await ThirdwebManager.Instance.SDK.storage.UploadText(metaJson);`;
|
||||
|
||||
const tsCode = `\
|
||||
type User = {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
function logUser(user: User) {
|
||||
console.log(user)
|
||||
}
|
||||
`;
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
|
||||
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||
import type { ThemeProviderProps } from "next-themes";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Highlight } from "prism-react-renderer";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Card } from "./card";
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
className?: string;
|
||||
canCopy?: boolean;
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language = "sh",
|
||||
canCopy = true,
|
||||
}: CodeBlockProps) => {
|
||||
return (
|
||||
<Highlight code={code} language={language}>
|
||||
{({ className, tokens, getLineProps, getTokenProps }) => (
|
||||
<Card className={className}>
|
||||
{canCopy && (
|
||||
<CopyButton text={code} className="float-right m-2 p-2" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"block overflow-auto whitespace-pre-wrap p-4 font-mono text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index IS the key here
|
||||
<div {...getLineProps({ line, key: i })} key={i}>
|
||||
{line.map((token, key) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index IS the key here
|
||||
<span {...getTokenProps({ token, key })} key={key} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
};
|
||||
@@ -69,14 +69,14 @@ export function DatePickerWithRange(props: {
|
||||
<DynamicHeight>
|
||||
<div>
|
||||
{!isValid && (
|
||||
<p className="flex items-center justify-center gap-2 pt-2 text-center text-destructive-text text-sm">
|
||||
<p className="flex items-center justify-center gap-2 py-2 text-center text-destructive-text text-sm">
|
||||
<CalendarX2Icon className="h-4 w-4" />
|
||||
Invalid date range
|
||||
</p>
|
||||
)}
|
||||
{props.header}
|
||||
|
||||
<div className="px-4">
|
||||
<div className={cn("px-4", !props.header && "py-4")}>
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
activeTabClassName="!bg-inverted !text-inverted-foreground"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-border border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
export function PlainTextCodeBlock(props: {
|
||||
code: string;
|
||||
copyButtonClassName?: string;
|
||||
}) {
|
||||
const { hasCopied, onCopy } = useClipboard(props.code);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<code className="block whitespace-pre rounded-lg border border-border bg-muted/50 p-4">
|
||||
{props.code}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3.5 h-auto p-2",
|
||||
props.copyButtonClassName,
|
||||
)}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<CheckIcon className="size-3 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function RenderCode(props: {
|
||||
code: string;
|
||||
html: string;
|
||||
className?: string;
|
||||
scrollableClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative max-w-full overflow-hidden rounded-lg border border-border bg-background",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<ScrollShadow
|
||||
scrollableClassName={cn("p-4", props.scrollableClassName)}
|
||||
className="text-xs md:text-sm [&_*]:leading-relaxed"
|
||||
shadowColor="hsl(var(--muted))"
|
||||
>
|
||||
<div
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: we know what we're doing here
|
||||
dangerouslySetInnerHTML={{ __html: props.html }}
|
||||
/>
|
||||
</ScrollShadow>
|
||||
<CopyButton
|
||||
text={props.code}
|
||||
iconClassName="size-3"
|
||||
className="absolute top-4 right-4 z-10 border border-border bg-background p-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton(props: {
|
||||
text: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
return (
|
||||
<ToolTipLabel label="Copy">
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Copy"
|
||||
className={cn("h-auto w-auto p-1", props.className)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(props.text);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon
|
||||
className={cn("size-4 text-green-500", props.iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
className={cn("size-4 text-muted-foreground", props.iconClassName)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
import { Spinner } from "../../ui/Spinner/Spinner";
|
||||
import { RenderCode } from "./RenderCode";
|
||||
import { getCodeHtml } from "./getCodeHtml";
|
||||
|
||||
export type CodeProps = {
|
||||
code: string;
|
||||
lang: BundledLanguage;
|
||||
className?: string;
|
||||
scrollableClassName?: string;
|
||||
loadingClassName?: string;
|
||||
keepPreviousDataOnCodeChange?: boolean;
|
||||
};
|
||||
|
||||
export const CodeClient: React.FC<CodeProps> = ({
|
||||
code,
|
||||
lang,
|
||||
className,
|
||||
scrollableClassName,
|
||||
loadingClassName,
|
||||
keepPreviousDataOnCodeChange = false,
|
||||
}) => {
|
||||
const codeQuery = useQuery({
|
||||
queryKey: ["html", code],
|
||||
queryFn: () => getCodeHtml(code, lang),
|
||||
placeholderData: keepPreviousDataOnCodeChange
|
||||
? keepPreviousData
|
||||
: undefined,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (!codeQuery.data) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[200px] items-center justify-center rounded-lg border border-border",
|
||||
loadingClassName,
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderCode
|
||||
code={codeQuery.data.formattedCode}
|
||||
html={codeQuery.data.html}
|
||||
className={className}
|
||||
scrollableClassName={scrollableClassName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BundledLanguage } from "shiki";
|
||||
import { RenderCode } from "./RenderCode";
|
||||
import { getCodeHtml } from "./getCodeHtml";
|
||||
|
||||
export type CodeProps = {
|
||||
code: string;
|
||||
lang: BundledLanguage;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CodeServer: React.FC<CodeProps> = async ({
|
||||
code,
|
||||
lang,
|
||||
className,
|
||||
}) => {
|
||||
const { html, formattedCode } = await getCodeHtml(code, lang);
|
||||
return <RenderCode code={formattedCode} html={html} className={className} />;
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { CodeClient } from "./code.client";
|
||||
|
||||
const meta = {
|
||||
title: "code/lang",
|
||||
component: Component,
|
||||
parameters: {},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const tsCode = `\
|
||||
type User = {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
function logUser(user: User) {
|
||||
console.log(user)
|
||||
}
|
||||
`;
|
||||
|
||||
const jsCode = `\
|
||||
import { getContract } from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { getOwnedNFTs } from "thirdweb/extensions/erc1155";
|
||||
|
||||
const contract = getContract({
|
||||
client,
|
||||
address: "0x1234...",
|
||||
chain: sepolia,
|
||||
});
|
||||
`;
|
||||
|
||||
const jsxCode = `\
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
|
||||
function Main() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<App />
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}`;
|
||||
|
||||
const tsxCode = `\
|
||||
type User = {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
function UserInfo(props: { user: User }) {
|
||||
return <div> {props.user.name} </div>
|
||||
}
|
||||
`;
|
||||
|
||||
const tsxCodeWithFormError = `\
|
||||
// This piece of code has invalid syntax and can't be formatted by prettier
|
||||
// this should not crash the page - and be rendered as is without formatting
|
||||
// the format error is logged in console
|
||||
|
||||
type User = // missing { here
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
function UserInfo(props: { user: User }) {
|
||||
return <div> {props.user.name} </div>
|
||||
}
|
||||
`;
|
||||
|
||||
const bashCode = "pnpm i thirdweb";
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container flex max-w-[600px] flex-col gap-10 py-10">
|
||||
<BadgeContainer label="ts">
|
||||
<CodeClient code={tsCode} lang="ts" />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="js">
|
||||
<CodeClient code={jsCode} lang="js" />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="bash">
|
||||
<CodeClient code={bashCode} lang="bash" />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="jsx">
|
||||
<CodeClient code={jsxCode} lang="jsx" />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="tsx">
|
||||
<CodeClient code={tsxCode} lang="tsx" />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="tsx">
|
||||
<CodeClient code={tsxCodeWithFormError} lang="tsx" />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as parserBabel from "prettier/plugins/babel";
|
||||
import * as estree from "prettier/plugins/estree";
|
||||
import { format } from "prettier/standalone";
|
||||
import { type BundledLanguage, codeToHtml } from "shiki";
|
||||
|
||||
function isPrettierSupportedLang(lang: BundledLanguage) {
|
||||
return (
|
||||
lang === "js" ||
|
||||
lang === "jsx" ||
|
||||
lang === "ts" ||
|
||||
lang === "tsx" ||
|
||||
lang === "javascript" ||
|
||||
lang === "typescript" ||
|
||||
lang === "css" ||
|
||||
lang === "json"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCodeHtml(code: string, lang: BundledLanguage) {
|
||||
const formattedCode = isPrettierSupportedLang(lang)
|
||||
? await format(code, {
|
||||
parser: "babel-ts",
|
||||
plugins: [parserBabel, estree],
|
||||
printWidth: 60,
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
console.error("Failed to format code");
|
||||
console.log({
|
||||
code,
|
||||
lang,
|
||||
});
|
||||
return code;
|
||||
})
|
||||
: code;
|
||||
|
||||
const html = await codeToHtml(formattedCode, {
|
||||
lang: lang,
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark-default",
|
||||
},
|
||||
});
|
||||
|
||||
return { html, formattedCode };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { PlainTextCodeBlock } from "./plaintext-code";
|
||||
|
||||
const meta = {
|
||||
title: "code/plaintext",
|
||||
component: Component,
|
||||
parameters: {},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const jsCode = `\
|
||||
import { getContract } from "thirdweb";
|
||||
import { sepolia } from "thirdweb/chains";
|
||||
import { getOwnedNFTs } from "thirdweb/extensions/erc1155";
|
||||
|
||||
const contract = getContract({
|
||||
client,
|
||||
address: "0x1234...",
|
||||
chain: sepolia,
|
||||
});
|
||||
`;
|
||||
|
||||
const overflowText =
|
||||
"123456789abcdefghijklmnopqrstuvwxyz123456789abcdefghijklmnopqrstuvwxyz123456789abcdefghijklmnopqrstuvwxyz123456789abcdefghijklmnopqrstuvwxyz123456789abcdefghijklmnopqrstuvwxyz123456789abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container flex max-w-[600px] flex-col gap-10 py-10">
|
||||
<BadgeContainer label="large chunk of code">
|
||||
<PlainTextCodeBlock code={jsCode} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="overflow test">
|
||||
<PlainTextCodeBlock code={overflowText} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { ScrollShadow } from "../ScrollShadow/ScrollShadow";
|
||||
import { Button } from "../button";
|
||||
|
||||
export function PlainTextCodeBlock(props: {
|
||||
code: string;
|
||||
copyButtonClassName?: string;
|
||||
className?: string;
|
||||
scrollableClassName?: string;
|
||||
codeClassName?: string;
|
||||
}) {
|
||||
const { hasCopied, onCopy } = useClipboard(props.code);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative max-w-full overflow-hidden rounded-lg border border-border bg-background",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<ScrollShadow
|
||||
scrollableClassName={cn("p-4", props.scrollableClassName)}
|
||||
className="text-xs md:text-sm [&_*]:leading-relaxed"
|
||||
shadowColor="hsl(var(--muted))"
|
||||
>
|
||||
<code className={cn("block whitespace-pre", props.codeClassName)}>
|
||||
{props.code}
|
||||
</code>
|
||||
</ScrollShadow>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCopy}
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3.5 h-auto bg-background p-2",
|
||||
props.copyButtonClassName,
|
||||
)}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<CheckIcon className="size-3 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,40 +35,43 @@ const DialogContent = React.forwardRef<
|
||||
dialogOverlayClassName?: string;
|
||||
dialogCloseClassName?: string;
|
||||
}
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay className={props.dialogOverlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
|
||||
"right-0 bottom-0 left-0",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
// on desktop - vertically and horizontally center the dialog, animate - zoom and fade in
|
||||
"md:top-[50%] md:bottom-auto md:left-[50%] md:translate-x-[-50%] md:translate-y-[-50%] ",
|
||||
"md:data-[state=closed]:zoom-out-95 md:data-[state=open]:zoom-in-95 ",
|
||||
"md:data-[state=closed]:slide-out-to-left-1/2 md:data-[state=closed]:slide-out-to-top-[48%] ",
|
||||
"md:data-[state=open]:slide-in-from-left-1/2 md:data-[state=open]:slide-in-from-top-[48%] ",
|
||||
"data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { dialogOverlayClassName, dialogCloseClassName, ...restProps } = props;
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay className={dialogOverlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||
props.dialogCloseClassName,
|
||||
"fixed grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
|
||||
"right-0 bottom-0 left-0",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
// on desktop - vertically and horizontally center the dialog, animate - zoom and fade in
|
||||
"md:top-[50%] md:bottom-auto md:left-[50%] md:translate-x-[-50%] md:translate-y-[-50%] ",
|
||||
"md:data-[state=closed]:zoom-out-95 md:data-[state=open]:zoom-in-95 ",
|
||||
"md:data-[state=closed]:slide-out-to-left-1/2 md:data-[state=closed]:slide-out-to-top-[48%] ",
|
||||
"md:data-[state=open]:slide-in-from-left-1/2 md:data-[state=open]:slide-in-from-top-[48%] ",
|
||||
"data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||
dialogCloseClassName,
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
|
||||
const meta = {
|
||||
title: "Shadcn/select",
|
||||
title: "Shadcn/Select",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
|
||||
@@ -73,19 +73,7 @@ const SelectContent = React.forwardRef<
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
// Fixes https://github.com/radix-ui/primitives/issues/1658
|
||||
ref={(instance) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(instance);
|
||||
} else if (ref) {
|
||||
ref.current = instance;
|
||||
}
|
||||
if (!instance) return;
|
||||
|
||||
instance.ontouchstart = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
}}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
position === "popper" &&
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import Link from "next/link";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
const meta = {
|
||||
title: "Shadcn/table",
|
||||
title: "Shadcn/Table",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
@@ -41,6 +43,10 @@ function Component() {
|
||||
<TableDemo />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Clickable Row">
|
||||
<TableDemo linkBox />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="With Footer">
|
||||
<TableDemo footer />
|
||||
</BadgeContainer>
|
||||
@@ -90,6 +96,7 @@ const invoices: Invoice[] = [
|
||||
|
||||
function TableDemo(props: {
|
||||
footer?: boolean;
|
||||
linkBox?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableContainer>
|
||||
@@ -104,8 +111,21 @@ function TableDemo(props: {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.invoice}>
|
||||
<TableCell className="font-medium">{invoice.invoice}</TableCell>
|
||||
<TableRow
|
||||
key={invoice.invoice}
|
||||
linkBox={props.linkBox}
|
||||
className={cn(props.linkBox && "cursor-pointer hover:bg-muted")}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/invoices/${invoice.invoice}`}
|
||||
className={cn(
|
||||
props.linkBox && "before:absolute before:inset-0",
|
||||
)}
|
||||
>
|
||||
{invoice.invoice}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{invoice.paymentStatus}</Badge>
|
||||
</TableCell>
|
||||
|
||||
@@ -63,12 +63,19 @@ TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.HTMLAttributes<HTMLTableRowElement> & {
|
||||
/**
|
||||
* Contain the absolutely position elements inside the row with position:relative + transform:translate(0)
|
||||
* transform:translate(0) is required because position:relative on tr element does not work on webkit
|
||||
*/
|
||||
linkBox?: boolean;
|
||||
}
|
||||
>(({ className, linkBox, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border border-b last:border-0 data-[state=selected]:bg-muted",
|
||||
linkBox && "relative translate-x-0 translate-y-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -87,6 +87,8 @@ export function TabButtons(props: {
|
||||
tabContainerClassName?: string;
|
||||
containerClassName?: string;
|
||||
shadowColor?: string;
|
||||
tabIconClassName?: string;
|
||||
hideBottomLine?: boolean;
|
||||
}) {
|
||||
const { containerRef, lineRef, activeTabRef } =
|
||||
useUnderline<HTMLButtonElement>();
|
||||
@@ -94,7 +96,9 @@ export function TabButtons(props: {
|
||||
return (
|
||||
<div className={cn("relative", props.containerClassName)}>
|
||||
{/* Bottom line */}
|
||||
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
|
||||
{!props.hideBottomLine && (
|
||||
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
|
||||
)}
|
||||
|
||||
<ScrollShadow
|
||||
scrollableClassName="pb-[8px] relative"
|
||||
@@ -120,7 +124,9 @@ export function TabButtons(props: {
|
||||
)}
|
||||
onClick={tab.isEnabled ? tab.onClick : undefined}
|
||||
>
|
||||
{tab.icon && <tab.icon className="size-6" />}
|
||||
{tab.icon && (
|
||||
<tab.icon className={cn("size-6", props.tabIconClassName)} />
|
||||
)}
|
||||
{tab.name}
|
||||
</Button>
|
||||
);
|
||||
@@ -197,6 +203,7 @@ export function TabPathLinks(props: {
|
||||
isActive: l.exactMatch
|
||||
? pathname === l.path
|
||||
: pathname.startsWith(l.path),
|
||||
isDisabled: l.isDisabled,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const LOGGED_IN_ONLY_PATHS = [
|
||||
// anything that _starts_ with /dashboard is logged in only
|
||||
"/dashboard",
|
||||
// team pages are logged in only
|
||||
"/team",
|
||||
// anything that _starts_ with /cli is logged in only
|
||||
"/cli",
|
||||
// publish page
|
||||
"/contracts/publish",
|
||||
];
|
||||
|
||||
export function isLoginRequired(pathname: string) {
|
||||
return LOGGED_IN_ONLY_PATHS.some((path) => pathname.startsWith(path));
|
||||
// remove '/' in front and then split by '/'
|
||||
const paths = pathname.slice(1).split("/");
|
||||
|
||||
// /team, /cli
|
||||
if (paths[0] === "team" || paths[0] === "cli") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// /contracts/publish
|
||||
if (paths[0] === "contracts" && paths[1] === "publish") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { cookies } from "next/headers";
|
||||
export const COOKIE_ACTIVE_ACCOUNT = "tw_active_account";
|
||||
export const COOKIE_PREFIX_TOKEN = "tw_token_";
|
||||
|
||||
export function getActiveAccountCookie() {
|
||||
return cookies().get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
export async function getActiveAccountCookie() {
|
||||
return (await cookies()).get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
}
|
||||
|
||||
export function getJWTCookie(address: string) {
|
||||
return cookies().get(COOKIE_PREFIX_TOKEN + address)?.value;
|
||||
export async function getJWTCookie(address: string) {
|
||||
return (await cookies()).get(COOKIE_PREFIX_TOKEN + address)?.value;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ export const IPFS_GATEWAY_URL =
|
||||
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
|
||||
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
|
||||
|
||||
export const THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
|
||||
|
||||
export const isProd =
|
||||
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
|
||||
"production";
|
||||
@@ -24,5 +21,28 @@ export const DASHBOARD_STORAGE_URL =
|
||||
export const API_SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
|
||||
|
||||
/**
|
||||
* Faucet stuff
|
||||
*/
|
||||
// Cloudflare Turnstile Site key
|
||||
export const TURNSTILE_SITE_KEY =
|
||||
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";
|
||||
export const THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
|
||||
export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
// Comma-separated list of chain IDs to disable faucet for.
|
||||
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
|
||||
|
||||
export function getAbsoluteUrlFromPath(path: string) {
|
||||
const url = new URL(
|
||||
isProd
|
||||
? "https://thirdweb.com"
|
||||
: (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
|
||||
: "http://localhost:3000") || "https://thirdweb-dev.com",
|
||||
);
|
||||
|
||||
url.pathname = path;
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ export function useThirdwebClient(jwt?: string) {
|
||||
throw new Error("Failed to get auth token");
|
||||
}
|
||||
const json = (await res.json()) as GetAuthTokenResponse;
|
||||
return json.jwt || undefined;
|
||||
if (!json.jwt) {
|
||||
throw new Error("No JWT in response");
|
||||
}
|
||||
return json.jwt;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IPFS_GATEWAY_URL,
|
||||
} from "@/constants/env";
|
||||
import {
|
||||
THIRDWEB_BUNDLER_DOMAIN,
|
||||
THIRDWEB_INAPP_WALLET_DOMAIN,
|
||||
THIRDWEB_PAY_DOMAIN,
|
||||
THIRDWEB_RPC_DOMAIN,
|
||||
@@ -11,7 +12,13 @@ import {
|
||||
THIRDWEB_STORAGE_DOMAIN,
|
||||
} from "constants/urls";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { setThirdwebDomains } from "thirdweb/utils";
|
||||
import { populateEip712Transaction } from "thirdweb/transaction";
|
||||
import {
|
||||
getTransactionDecorator,
|
||||
setThirdwebDomains,
|
||||
setTransactionDecorator,
|
||||
} from "thirdweb/utils";
|
||||
import { getZkPaymasterData } from "thirdweb/wallets/smart";
|
||||
import { getVercelEnv } from "../../lib/vercel-utils";
|
||||
|
||||
// returns a thirdweb client with optional JWT passed in
|
||||
@@ -24,8 +31,43 @@ export function getThirdwebClient(jwt?: string) {
|
||||
pay: THIRDWEB_PAY_DOMAIN,
|
||||
storage: THIRDWEB_STORAGE_DOMAIN,
|
||||
social: THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
bundler: THIRDWEB_BUNDLER_DOMAIN,
|
||||
});
|
||||
}
|
||||
|
||||
if (!getTransactionDecorator()) {
|
||||
setTransactionDecorator(async ({ account, transaction }) => {
|
||||
// special override for sophon testnet (zk chain)
|
||||
// sophon only allows transactions through their paymaster
|
||||
// so always use eip712 tx + paymaster
|
||||
if (transaction.chain.id === 531050104) {
|
||||
const serializedTx = await populateEip712Transaction({
|
||||
transaction,
|
||||
account,
|
||||
});
|
||||
const pmData = await getZkPaymasterData({
|
||||
options: {
|
||||
client: transaction.client,
|
||||
chain: transaction.chain,
|
||||
},
|
||||
transaction: serializedTx,
|
||||
});
|
||||
return {
|
||||
account,
|
||||
transaction: {
|
||||
...transaction,
|
||||
eip712: {
|
||||
...transaction.eip712,
|
||||
paymaster: pmData.paymaster,
|
||||
paymasterInput: pmData.paymasterInput,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { account, transaction };
|
||||
});
|
||||
}
|
||||
|
||||
return createThirdwebClient({
|
||||
secretKey: jwt ? jwt : DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
clientId: DASHBOARD_THIRDWEB_CLIENT_ID,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// keep in line with product SKUs in the backend
|
||||
export type ProductSKU =
|
||||
| "plan:starter"
|
||||
| "plan:growth"
|
||||
| "plan:custom"
|
||||
| "product:ecosystem_wallets"
|
||||
| "product:engine_standard"
|
||||
| "product:engine_premium"
|
||||
| "usage:storage"
|
||||
| "usage:in_app_wallet"
|
||||
| "usage:aa_sponsorship"
|
||||
| "usage:aa_sponsorship_op_grant"
|
||||
| null;
|
||||
@@ -156,3 +156,13 @@ input:-webkit-autofill:active {
|
||||
/* Revert background color */
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--background) !important;
|
||||
/* Optional, if you also want font styles */
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSDKTheme } from "app/components/sdk-component-theme";
|
||||
import { CustomChainRenderer } from "components/selects/CustomChainRenderer";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
@@ -15,7 +16,6 @@ import { usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { Chain } from "thirdweb";
|
||||
import {
|
||||
AutoConnect,
|
||||
ConnectButton,
|
||||
type NetworkSelectorProps,
|
||||
useConnectModal,
|
||||
@@ -34,7 +34,10 @@ import { popularChains } from "../popularChains";
|
||||
export const CustomConnectWallet = (props: {
|
||||
loginRequired?: boolean;
|
||||
connectButtonClassName?: string;
|
||||
signInLinkButtonClassName?: string;
|
||||
detailsButtonClassName?: string;
|
||||
loadingButtonClassName?: string;
|
||||
chain?: Chain;
|
||||
}) => {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
const loginRequired =
|
||||
@@ -122,11 +125,14 @@ export const CustomConnectWallet = (props: {
|
||||
if (isPending) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[48px] w-[144px] items-center justify-center rounded-lg border border-border bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[48px] w-[144px] items-center justify-center rounded-lg border border-border bg-muted",
|
||||
props.loadingButtonClassName,
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/* need auto connect here so that we actually connect */}
|
||||
<AutoConnect client={thirdwebClient} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -134,15 +140,18 @@ export const CustomConnectWallet = (props: {
|
||||
if (!isLoggedIn && loginRequired) {
|
||||
return (
|
||||
<>
|
||||
<Button asChild variant="default" className="gap-2" size="lg">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
className={props.signInLinkButtonClassName}
|
||||
size="lg"
|
||||
>
|
||||
<Link
|
||||
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</Button>
|
||||
{/* need auto connect here so that we actually connect */}
|
||||
<AutoConnect client={thirdwebClient} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -203,6 +212,7 @@ export const CustomConnectWallet = (props: {
|
||||
},
|
||||
},
|
||||
}}
|
||||
chain={props.chain}
|
||||
/>
|
||||
|
||||
<LazyConfigureNetworkModal
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ComponentWithChildren } from "types/component-with-children";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface AdminOnlyProps {
|
||||
contract: ThirdwebContract;
|
||||
failOpen?: boolean;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { UserOpStats } from "@/api/analytics";
|
||||
import type { Team } from "@/api/team";
|
||||
import {
|
||||
type Query,
|
||||
useMutation,
|
||||
@@ -28,9 +30,6 @@ export const accountPlan = {
|
||||
enterprise: "enterprise",
|
||||
} as const;
|
||||
|
||||
export type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus];
|
||||
export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
|
||||
|
||||
export type AuthorizedWallet = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
@@ -46,30 +45,17 @@ export type Account = {
|
||||
id: string;
|
||||
isStaff: boolean;
|
||||
creatorWalletAddress: string;
|
||||
status: AccountStatus;
|
||||
plan: AccountPlan;
|
||||
name?: string;
|
||||
email?: string;
|
||||
advancedEnabled: boolean;
|
||||
currentBillingPeriodStartsAt: string;
|
||||
currentBillingPeriodEndsAt: string;
|
||||
emailConfirmedAt?: string;
|
||||
unconfirmedEmail?: string;
|
||||
trialPeriodEndedAt?: string;
|
||||
emailConfirmationWalletAddress?: string;
|
||||
stripePaymentActionUrl?: string;
|
||||
onboardSkipped?: boolean;
|
||||
paymentAttemptCount?: number;
|
||||
notificationPreferences?: {
|
||||
billing: "email" | "none";
|
||||
updates: "email" | "none";
|
||||
};
|
||||
recurringPaymentFailures: {
|
||||
subscriptionId: string;
|
||||
subscriptionDescription: string;
|
||||
paymentFailureCode: string;
|
||||
serviceCutoffDate: string;
|
||||
}[];
|
||||
// TODO - add image URL
|
||||
};
|
||||
|
||||
@@ -193,33 +179,16 @@ export interface UpdateKeyInput {
|
||||
redirectUrls: string[];
|
||||
}
|
||||
|
||||
interface UsageBundler {
|
||||
chainId: number;
|
||||
sumTransactionFee: string;
|
||||
}
|
||||
|
||||
interface UsageStorage {
|
||||
sumFileSizeBytes: number;
|
||||
}
|
||||
|
||||
interface UsageEmbeddedWallets {
|
||||
countWalletAddresses: number;
|
||||
}
|
||||
|
||||
export interface UsageBillableByService {
|
||||
usage: {
|
||||
bundler: UsageBundler[];
|
||||
storage: UsageStorage;
|
||||
embeddedWallets: UsageEmbeddedWallets;
|
||||
};
|
||||
billableUsd: {
|
||||
bundler: number;
|
||||
storage: number;
|
||||
embeddedWallets: number;
|
||||
};
|
||||
limits: {
|
||||
storage: number;
|
||||
embeddedWallets: number;
|
||||
};
|
||||
rateLimits: {
|
||||
storage: number;
|
||||
@@ -231,34 +200,6 @@ export interface UsageBillableByService {
|
||||
};
|
||||
}
|
||||
|
||||
export interface WalletStats {
|
||||
date: string;
|
||||
uniqueWalletsConnected: number;
|
||||
totalConnections: number;
|
||||
walletType: string;
|
||||
}
|
||||
|
||||
export interface InAppWalletStats {
|
||||
date: string;
|
||||
authenticationMethod: string;
|
||||
uniqueWalletsConnected: number;
|
||||
}
|
||||
|
||||
export interface UserOpStats {
|
||||
date: string;
|
||||
successful: number;
|
||||
failed: number;
|
||||
sponsoredUsd: number;
|
||||
}
|
||||
|
||||
export interface UserOpStatsByChain {
|
||||
date: string;
|
||||
successful: number;
|
||||
failed: number;
|
||||
sponsoredUsd: number;
|
||||
chainId?: string;
|
||||
}
|
||||
|
||||
interface BillingProduct {
|
||||
name: string;
|
||||
id: string;
|
||||
@@ -315,30 +256,6 @@ export function useAccount({ refetchInterval }: UseAccountInput = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccountUsage() {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.usage(user?.address as string),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/usage`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data as UsageBillableByService;
|
||||
},
|
||||
enabled: !!user?.address && isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccountCredits() {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
@@ -371,43 +288,6 @@ export function useAccountCredits() {
|
||||
});
|
||||
}
|
||||
|
||||
async function getWalletUsage(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("clientId", clientId);
|
||||
if (from) {
|
||||
searchParams.append("from", from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.append("to", to.toISOString());
|
||||
}
|
||||
if (period) {
|
||||
searchParams.append("period", period);
|
||||
}
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_ANALYTICS_API_HOST}/v1/wallets?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(json.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function getUserOpUsage(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
@@ -445,43 +325,6 @@ async function getUserOpUsage(args: {
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function getInAppWalletUsage(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("clientId", clientId);
|
||||
if (from) {
|
||||
searchParams.append("from", from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.append("to", to.toISOString());
|
||||
}
|
||||
if (period) {
|
||||
searchParams.append("period", period);
|
||||
}
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_ANALYTICS_API_HOST}/v1/wallets/in-app?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const json = await res?.json();
|
||||
|
||||
if (!res || res.status !== 200) {
|
||||
throw new Error(json.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export function useUserOpUsageAggregate(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
@@ -565,63 +408,6 @@ export function useUserOpUsagePeriod(args: {
|
||||
});
|
||||
}
|
||||
|
||||
export function useWalletUsageAggregate(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}) {
|
||||
const { clientId, from, to } = args;
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.walletStats(
|
||||
user?.address as string,
|
||||
clientId as string,
|
||||
from?.toISOString() || "",
|
||||
to?.toISOString() || "",
|
||||
"all",
|
||||
),
|
||||
queryFn: async () => {
|
||||
return getWalletUsage({
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
period: "all",
|
||||
});
|
||||
},
|
||||
enabled: !!clientId && !!user?.address && isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWalletUsagePeriod(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period: "day" | "week" | "month" | "year";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.walletStats(
|
||||
user?.address as string,
|
||||
clientId as string,
|
||||
from?.toISOString() || "",
|
||||
to?.toISOString() || "",
|
||||
period,
|
||||
),
|
||||
queryFn: async () => {
|
||||
return getWalletUsage({
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
period,
|
||||
});
|
||||
},
|
||||
enabled: !!clientId && !!user?.address && isLoggedIn,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccount() {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -656,51 +442,6 @@ export function useUpdateAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccountPlan(waitForWebhook?: boolean) {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: { plan: string; feedback?: string }) => {
|
||||
invariant(user?.address, "walletAddress is required");
|
||||
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/plan`, {
|
||||
method: "PUT",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
// Wait for account plan to update via stripe webhook
|
||||
// TODO: find a better way to notify the client that the plan has been updated
|
||||
if (waitForWebhook) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * 10));
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
return Promise.all([
|
||||
// invalidate usage data as limits are different
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(user?.address as string),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.usage(user?.address as string),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNotifications() {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -733,36 +474,6 @@ export function useUpdateNotifications() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBillingSession(enabled = false) {
|
||||
const { user } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.billingSession(user?.address as string),
|
||||
queryFn: async () => {
|
||||
invariant(user?.address, "walletAddress is required");
|
||||
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_API_HOST}/v1/account/billingSession`,
|
||||
{
|
||||
method: "GET",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfirmEmail() {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -785,7 +496,7 @@ export function useConfirmEmail() {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
return json.data as { team: Team; account: Account };
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// invalidate related cache, since could be relinking account
|
||||
@@ -839,40 +550,6 @@ export function useResendEmailConfirmation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePaymentMethod() {
|
||||
const { user } = useLoggedInUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (paymentMethodId: string) => {
|
||||
invariant(user?.address, "walletAddress is required");
|
||||
|
||||
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/paymentMethod`, {
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paymentMethodId,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(user?.address as string),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApiKeys() {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
return useQuery({
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { THIRDWEB_EWS_API_HOST } from "constants/urls";
|
||||
import type { WalletUser } from "thirdweb/wallets";
|
||||
import { embeddedWalletsKeys } from "../cache-keys";
|
||||
import { useLoggedInUser } from "./useLoggedInUser";
|
||||
|
||||
const fetchAccountList = ({
|
||||
jwt,
|
||||
clientId,
|
||||
pageNumber,
|
||||
}: { jwt: string; clientId: string; pageNumber: number }) => {
|
||||
return async () => {
|
||||
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
url.searchParams.append("page", pageNumber.toString());
|
||||
|
||||
const res = await fetch(url.href, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch wallets: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json as {
|
||||
users: WalletUser[];
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function useEmbeddedWallets(clientId: string, page: number) {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
@@ -13,31 +47,44 @@ export function useEmbeddedWallets(clientId: string, page: number) {
|
||||
clientId,
|
||||
page,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const url = new URL(
|
||||
`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`,
|
||||
);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
url.searchParams.append("page", page.toString());
|
||||
|
||||
const res = await fetch(url.href, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${user?.jwt}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch wallets: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json as {
|
||||
users: WalletUser[];
|
||||
totalPages: number;
|
||||
};
|
||||
},
|
||||
queryFn: fetchAccountList({
|
||||
jwt: user?.jwt ?? "",
|
||||
clientId,
|
||||
pageNumber: page,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: !!user?.address && isLoggedIn && !!clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllEmbeddedWallets() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useLoggedInUser();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
clientId,
|
||||
totalPages,
|
||||
}: { clientId: string; totalPages: number }) => {
|
||||
const walletRes = [];
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const res = queryClient.fetchQuery<{
|
||||
users: WalletUser[];
|
||||
totalPages: number;
|
||||
}>({
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
user?.address as string,
|
||||
clientId,
|
||||
page,
|
||||
),
|
||||
queryFn: fetchAccountList({
|
||||
jwt: user?.jwt ?? "",
|
||||
clientId,
|
||||
pageNumber: page,
|
||||
}),
|
||||
});
|
||||
walletRes.push(res);
|
||||
}
|
||||
return (await Promise.all(walletRes)).flatMap((res) => res.users);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ResultItem } from "app/team/[team_slug]/[project_slug]/engine/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import type { ResultItem } from "app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import { THIRDWEB_API_HOST } from "constants/urls";
|
||||
import type { EngineBackendWalletType } from "lib/engine";
|
||||
import { useState } from "react";
|
||||
@@ -116,7 +116,8 @@ type EngineFeature =
|
||||
| "KEYPAIR_AUTH"
|
||||
| "CONTRACT_SUBSCRIPTIONS"
|
||||
| "IP_ALLOWLIST"
|
||||
| "HETEROGENEOUS_WALLET_TYPES";
|
||||
| "HETEROGENEOUS_WALLET_TYPES"
|
||||
| "SMART_BACKEND_WALLETS";
|
||||
|
||||
interface EngineSystemHealth {
|
||||
status: string;
|
||||
|
||||
+47
-7
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||
@@ -9,17 +8,22 @@ import {
|
||||
} from "@/constants/env";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toUnits } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { isOnboardingComplete } from "../../../../../../login/isOnboardingRequired";
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
@@ -49,6 +53,7 @@ export function FaucetButton({
|
||||
chain: ChainMetadata;
|
||||
amount: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const client = useThirdwebClient();
|
||||
const address = useActiveAccount()?.address;
|
||||
const chainId = chain.chainId;
|
||||
@@ -113,6 +118,9 @@ export function FaucetButton({
|
||||
},
|
||||
});
|
||||
|
||||
const accountQuery = useAccount();
|
||||
const userQuery = useLoggedInUser();
|
||||
|
||||
const canClaimFaucetQuery = useQuery({
|
||||
queryKey: ["testnet-faucet-can-claim", chainId],
|
||||
queryFn: async () => {
|
||||
@@ -133,6 +141,33 @@ export function FaucetButton({
|
||||
|
||||
const form = useForm<z.infer<typeof claimFaucetSchema>>();
|
||||
|
||||
// Force users to log in to claim the faucet
|
||||
if (!address || !userQuery.user) {
|
||||
return (
|
||||
<CustomConnectWallet
|
||||
loginRequired={true}
|
||||
loadingButtonClassName="!w-full"
|
||||
signInLinkButtonClassName="!w-full !h-auto !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm hover:!bg-primary/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (accountQuery.isPending) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2">
|
||||
Loading account <Spinner className="size-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accountQuery.data) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2" disabled>
|
||||
Failed to load account
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// loading state
|
||||
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
|
||||
return (
|
||||
@@ -145,7 +180,7 @@ export function FaucetButton({
|
||||
// faucet is empty
|
||||
if (isFaucetEmpty) {
|
||||
return (
|
||||
<Button variant="outline" disabled className="!opacity-100 w-full ">
|
||||
<Button variant="outline" disabled className="!opacity-100 w-full">
|
||||
Faucet is empty right now
|
||||
</Button>
|
||||
);
|
||||
@@ -168,12 +203,17 @@ export function FaucetButton({
|
||||
);
|
||||
}
|
||||
|
||||
if (!address) {
|
||||
if (!isOnboardingComplete(accountQuery.data)) {
|
||||
return (
|
||||
<CustomConnectWallet
|
||||
loginRequired={false}
|
||||
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
|
||||
/>
|
||||
<Button asChild className="w-full">
|
||||
<Link
|
||||
href={
|
||||
pathname ? `/login?next=${encodeURIComponent(pathname)}` : "/login"
|
||||
}
|
||||
>
|
||||
Verify your Email
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export function ChainHeader(props: ChainHeaderProps) {
|
||||
}
|
||||
/>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="https://thirdweb.com/dashboard" target="_blank">
|
||||
<Link href="/team" target="_blank">
|
||||
Get started with thirdweb
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -32,9 +32,10 @@ import { ChainHeader } from "./components/server/chain-header";
|
||||
|
||||
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: { params: { chain_id: string } }): Promise<Metadata> {
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
|
||||
const title = `${sanitizedChainName}: RPC and Chain Settings`;
|
||||
@@ -54,13 +55,12 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
// this is the dashboard layout file
|
||||
export default async function ChainPageLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
export default async function ChainPageLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: { chain_id: string };
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
const chain = await getChain(params.chain_id);
|
||||
|
||||
if (params.chain_id !== chain.slug) {
|
||||
@@ -205,7 +205,7 @@ export default async function ChainPageLayout({
|
||||
}
|
||||
/>
|
||||
<Button variant="primary">
|
||||
<Link href="https://thirdweb.com/dashboard" target="_blank">
|
||||
<Link href="/team" target="_blank">
|
||||
Get started
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -13,9 +13,10 @@ import { ChainCTA } from "./components/server/cta-card";
|
||||
import { ExplorersSection } from "./components/server/explorer-section";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { chain_id: string };
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}) {
|
||||
const chain = await getChain(props.params.chain_id);
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const isDeprecated = chain.status === "deprecated";
|
||||
|
||||
|
||||
@@ -3,18 +3,17 @@ import { CircleAlertIcon } from "lucide-react";
|
||||
import { TrendingContractSection } from "../../../../trending/components/trending-table";
|
||||
import { getChain } from "../../../utils";
|
||||
|
||||
// we're using searchParams here - use dynamic rendering
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { chain_id: string };
|
||||
searchParams: { page?: number; sortBy?: SortBy };
|
||||
params: Promise<{ chain_id: string }>;
|
||||
searchParams: Promise<{ page?: number; sortBy?: SortBy }>;
|
||||
}) {
|
||||
const chain = await getChain(props.params.chain_id);
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const topContracts = await fetchTopContracts({
|
||||
chainId: chain.chainId,
|
||||
page: props.searchParams.page,
|
||||
sortBy: props.searchParams.sortBy,
|
||||
page: searchParams.page,
|
||||
sortBy: searchParams.sortBy,
|
||||
perPage: 15,
|
||||
timeRange: "month",
|
||||
});
|
||||
@@ -24,22 +23,18 @@ export default async function Page(props: {
|
||||
<h2 className="mb-2 font-semibold text-2xl tracking-tighter">
|
||||
Popular Contracts
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Explore contracts on Ethereum and sort them by your preferred metrics
|
||||
</p>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
{topContracts.length > 0 && (
|
||||
<TrendingContractSection
|
||||
topContracts={topContracts}
|
||||
chainId={chain.chainId}
|
||||
searchParams={props.searchParams}
|
||||
searchParams={searchParams}
|
||||
showPagination={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{topContracts.length === 0 && (
|
||||
<div className="flex h-[200px] items-center justify-center rounded-lg border border-border text-lg text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
+27
-55
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ListerOnly } from "@3rdweb-sdk/react/components/roles/lister-only";
|
||||
import { useDisclosure } from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Button, Drawer } from "tw-components";
|
||||
import { CreateListingsForm } from "./list-form";
|
||||
|
||||
const LIST_FORM_ID = "marketplace-list-form";
|
||||
|
||||
interface CreateListingButtonProps {
|
||||
contract: ThirdwebContract;
|
||||
createText?: string;
|
||||
@@ -25,59 +28,28 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
...restButtonProps
|
||||
}) => {
|
||||
const address = useActiveAccount()?.address;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [isFormLoading, setIsFormLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ListerOnly contract={contract}>
|
||||
<Drawer
|
||||
allowPinchZoom
|
||||
preserveScrollBarGap
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
header={{ children: createText }}
|
||||
footer={{
|
||||
children: (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isFormLoading}
|
||||
variant="outline"
|
||||
mr={3}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
txChainID={contract.chain.id}
|
||||
isLoading={isFormLoading}
|
||||
transactionCount={2}
|
||||
form={LIST_FORM_ID}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
>
|
||||
{createText}
|
||||
</TransactionButton>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<CreateListingsForm
|
||||
contract={contract}
|
||||
formId={LIST_FORM_ID}
|
||||
type={type}
|
||||
setIsFormLoading={setIsFormLoading}
|
||||
/>
|
||||
</Drawer>
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
{...restButtonProps}
|
||||
onClick={onOpen}
|
||||
isDisabled={!address}
|
||||
>
|
||||
{createText}
|
||||
</Button>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="primary" {...restButtonProps} disabled={!address}>
|
||||
{createText} <PlusIcon className="ml-2 size-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="mb-5 text-left">{createText}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<CreateListingsForm
|
||||
contract={contract}
|
||||
type={type}
|
||||
actionText={createText}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</ListerOnly>
|
||||
);
|
||||
};
|
||||
|
||||
+73
-67
@@ -1,4 +1,5 @@
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useDashboardOwnedNFTs } from "@3rdweb-sdk/react/hooks/useDashboardOwnedNFTs";
|
||||
import { useWalletNFTs } from "@3rdweb-sdk/react/hooks/useWalletNFTs";
|
||||
@@ -10,19 +11,19 @@ import {
|
||||
Select,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
useModalContext,
|
||||
} from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { CurrencySelector } from "components/shared/CurrencySelector";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
|
||||
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
|
||||
import { isSimpleHashSupported } from "lib/wallet/nfts/simpleHash";
|
||||
import type { WalletNFT } from "lib/wallet/nfts/types";
|
||||
import { CircleAlertIcon, InfoIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -47,17 +48,12 @@ import type {
|
||||
CreateListingParams,
|
||||
} from "thirdweb/extensions/marketplace";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import {
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Link,
|
||||
Text,
|
||||
} from "tw-components";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
|
||||
const LIST_FORM_ID = "marketplace-list-form";
|
||||
|
||||
type ListForm =
|
||||
| (Omit<CreateListingParams, "quantity" | "currencyContractAddress"> & {
|
||||
currencyContractAddress: string;
|
||||
@@ -79,9 +75,9 @@ type ListForm =
|
||||
|
||||
type CreateListingsFormProps = {
|
||||
contract: ThirdwebContract;
|
||||
formId: string;
|
||||
actionText: string;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
type?: "direct-listings" | "english-auctions";
|
||||
setIsFormLoading: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
const auctionTimes = [
|
||||
@@ -96,14 +92,15 @@ const auctionTimes = [
|
||||
|
||||
export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
contract,
|
||||
formId,
|
||||
type,
|
||||
setIsFormLoading,
|
||||
actionText,
|
||||
setOpen,
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const chainId = contract.chain.id;
|
||||
const { idToChain } = useAllChainsData();
|
||||
const network = idToChain.get(chainId);
|
||||
const [isFormLoading, setIsFormLoading] = useState(false);
|
||||
|
||||
const isSupportedChain =
|
||||
chainId &&
|
||||
@@ -203,17 +200,10 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
|
||||
const noNfts = !nfts?.length;
|
||||
|
||||
const modalContext = useModalContext();
|
||||
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
"NFT listed successfully",
|
||||
"Failed to list NFT",
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
id={formId}
|
||||
className="flex flex-col gap-6 pb-16"
|
||||
id={LIST_FORM_ID}
|
||||
onSubmit={form.handleSubmit(async (formData) => {
|
||||
if (!formData.selected || !selectedContract) {
|
||||
return;
|
||||
@@ -247,12 +237,13 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
approved: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTx.mutateAsync(approveTx);
|
||||
} catch {
|
||||
setIsFormLoading(false);
|
||||
return toast.error("Failed to approve NFT for marketplace");
|
||||
}
|
||||
const promise = sendAndConfirmTx.mutateAsync(approveTx);
|
||||
toast.promise(promise, {
|
||||
loading: "Approving NFT for listing",
|
||||
success: "NFT approved succesfully",
|
||||
error: "Failed to approve NFT",
|
||||
});
|
||||
await promise;
|
||||
}
|
||||
|
||||
if (formData.listingType === "direct") {
|
||||
@@ -270,12 +261,14 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
pricePerToken: String(formData.pricePerToken),
|
||||
endTimestamp,
|
||||
});
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
modalContext.onClose();
|
||||
},
|
||||
onError,
|
||||
|
||||
const promise = sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => setOpen(false),
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Listing NFT",
|
||||
success: "NFT listed successfully",
|
||||
error: "Failed to list NFT",
|
||||
});
|
||||
} else if (formData.listingType === "auction") {
|
||||
let minimumBidAmountWei: bigint;
|
||||
@@ -323,16 +316,15 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
buyoutBidAmountWei * BigInt(formData.quantity),
|
||||
});
|
||||
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
const promise = sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
trackEvent({
|
||||
category: "marketplace",
|
||||
action: "add-listing",
|
||||
label: "success",
|
||||
network,
|
||||
});
|
||||
modalContext.onClose();
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
@@ -342,11 +334,16 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
network,
|
||||
error,
|
||||
});
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Creating auction",
|
||||
success: "Auction created successfully",
|
||||
error: "Failed to create auction",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to list NFT");
|
||||
}
|
||||
|
||||
@@ -354,9 +351,7 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
})}
|
||||
>
|
||||
<FormControl>
|
||||
<Heading as={FormLabel} size="label.lg">
|
||||
Select NFT
|
||||
</Heading>
|
||||
<FormLabel>Select NFT</FormLabel>
|
||||
<FormHelperText mb="8px">
|
||||
Select the NFT you want to list for sale
|
||||
</FormHelperText>
|
||||
@@ -364,24 +359,22 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
<Flex flexDir="column" gap={4} mb={4}>
|
||||
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
|
||||
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
|
||||
<Text color="orange.800" _dark={{ color: "orange.900" }}>
|
||||
<p className="text-orange-800 dark:text-orange-900">
|
||||
This chain is not supported by our NFT API yet, please enter the
|
||||
contract address of the NFT you want to list.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<FormControl
|
||||
isInvalid={!!form.formState.errors.selected?.contractAddress}
|
||||
>
|
||||
<Heading as={FormLabel} size="label.lg">
|
||||
Contract address
|
||||
</Heading>
|
||||
<FormLabel>Contract address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
{...form.register("selected.contractAddress", {
|
||||
required: "Contract address is required",
|
||||
})}
|
||||
placeholder=""
|
||||
placeholder="0x..."
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors.selected?.contractAddress?.message}
|
||||
@@ -455,21 +448,19 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
) : nfts && nfts.length === 0 ? (
|
||||
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
|
||||
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
|
||||
<Text color="orange.800" _dark={{ color: "orange.900" }}>
|
||||
<p className="text-orange-800 dark:text-orange-900">
|
||||
There are no NFTs owned by this wallet. You need NFTs to create a
|
||||
listing. You can create NFTs with thirdweb.{" "}
|
||||
<Link href="/explore/nft" color="blue.600" isExternal>
|
||||
<Link href="/explore/nft" color="blue.600" target="_blank">
|
||||
Explore NFT contracts
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</FormControl>
|
||||
<FormControl isRequired isDisabled={noNfts}>
|
||||
<Heading as={FormLabel} size="label.lg">
|
||||
Listing Currency
|
||||
</Heading>
|
||||
<FormLabel>Listing Currency</FormLabel>
|
||||
<CurrencySelector
|
||||
contractChainId={chainId}
|
||||
value={form.watch("currencyContractAddress")}
|
||||
@@ -482,11 +473,11 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl isRequired isDisabled={noNfts}>
|
||||
<Heading as={FormLabel} size="label.lg">
|
||||
<FormLabel>
|
||||
{form.watch("listingType") === "auction"
|
||||
? "Buyout Price Per Token"
|
||||
: "Listing Price"}
|
||||
</Heading>
|
||||
</FormLabel>
|
||||
<Input {...form.register("pricePerToken")} />
|
||||
<FormHelperText>
|
||||
{form.watch("listingType") === "auction"
|
||||
@@ -497,9 +488,7 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
{form.watch("selected")?.type?.toLowerCase() !== "erc721" && (
|
||||
<FormControl isRequired isDisabled={noNfts}>
|
||||
<div className="flex flex-row justify-between gap-2">
|
||||
<Heading as={FormLabel} size="label.lg">
|
||||
Quantity
|
||||
</Heading>
|
||||
<FormLabel>Quantity</FormLabel>
|
||||
</div>
|
||||
<Input {...form.register("quantity")} />
|
||||
<FormHelperText>
|
||||
@@ -510,18 +499,14 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
{form.watch("listingType") === "auction" && (
|
||||
<>
|
||||
<FormControl isRequired isDisabled={noNfts}>
|
||||
<Heading as={FormLabel} size="label.lg">
|
||||
Reserve Price Per Token
|
||||
</Heading>
|
||||
<FormLabel>Reserve Price Per Token</FormLabel>
|
||||
<Input {...form.register("reservePricePerToken")} />
|
||||
<FormHelperText>
|
||||
The minimum price per token necessary to bid on this auction
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<Heading as={FormLabel} size="label.lg">
|
||||
Auction Duration
|
||||
</Heading>
|
||||
<FormLabel>Auction Duration</FormLabel>
|
||||
<Select {...form.register("listingDurationInSeconds")}>
|
||||
{auctionTimes.map((time) => (
|
||||
<option key={time.value} value={time.value}>
|
||||
@@ -540,6 +525,27 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
<AlertTitle>No NFT selected</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Need to pin these at the bottom because this is a very long form */}
|
||||
<div className="fixed right-6 bottom-4 flex flex-row items-center justify-end gap-3">
|
||||
<Button
|
||||
disabled={isFormLoading}
|
||||
variant="default"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
txChainID={contract.chain.id}
|
||||
isLoading={isFormLoading}
|
||||
transactionCount={2}
|
||||
form={LIST_FORM_ID}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
>
|
||||
{actionText}
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
+31
-41
@@ -1,6 +1,8 @@
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Flex, GridItem, SimpleGrid, usePrevious } from "@chakra-ui/react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
@@ -9,7 +11,8 @@ import type {
|
||||
EnglishAuction,
|
||||
} from "thirdweb/extensions/marketplace";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Badge, Card, CodeBlock, Heading, Text } from "tw-components";
|
||||
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
import { CancelTab } from "./cancel-tab";
|
||||
import { LISTING_STATUS } from "./types";
|
||||
@@ -51,18 +54,20 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
height="150px"
|
||||
/>
|
||||
<Flex flexDir="column" gap={2} w="70%">
|
||||
<Heading size="title.lg">{renderData.asset.metadata.name}</Heading>
|
||||
<Text size="label.md" noOfLines={6}>
|
||||
<p className="font-bold text-lg">
|
||||
{renderData.asset.metadata.name}
|
||||
</Text>
|
||||
</p>
|
||||
<p className="line-clamp-6 truncate text-base leading-normal">
|
||||
{renderData.asset.metadata.name}
|
||||
</p>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Card as={Flex} flexDir="column" gap={3}>
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<SimpleGrid rowGap={3} columns={12} placeItems="center left">
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Asset contract address</Heading>
|
||||
<p className="font-bold">Asset contract address</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<CopyAddressButton
|
||||
@@ -71,7 +76,7 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Token ID</Heading>
|
||||
<p className="font-bold">Token ID</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<CopyTextButton
|
||||
@@ -82,13 +87,13 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Seller</Heading>
|
||||
<p className="font-bold">Seller</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<WalletAddress address={renderData.creatorAddress} />
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Listing ID</Heading>
|
||||
<p className="font-bold">Listing ID</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<CopyTextButton
|
||||
@@ -99,49 +104,37 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Type</Heading>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<Text fontFamily="mono" size="body.md">
|
||||
{renderData.asset.type}
|
||||
</Text>
|
||||
<p className="font-bold">Type</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>{renderData.asset.type}</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Status</Heading>
|
||||
<p className="font-bold">Status</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<Badge
|
||||
size="label.sm"
|
||||
variant="subtle"
|
||||
textTransform="capitalize"
|
||||
>
|
||||
<Badge className="uppercase">
|
||||
{LISTING_STATUS[renderData.status]}
|
||||
</Badge>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Quantity</Heading>
|
||||
<p className="font-bold">Quantity</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<Text fontFamily="mono" size="body.md">
|
||||
{(renderData.quantity || 0n).toString()}{" "}
|
||||
{/* For listings that are completed, the `quantity` would be `0`
|
||||
{(renderData.quantity || 0n).toString()}{" "}
|
||||
{/* For listings that are completed, the `quantity` would be `0`
|
||||
So we show this text to make it clear */}
|
||||
{LISTING_STATUS[renderData.status] === "Completed"
|
||||
? "(Sold out)"
|
||||
: ""}
|
||||
</Text>
|
||||
{LISTING_STATUS[renderData.status] === "Completed"
|
||||
? "(Sold out)"
|
||||
: ""}
|
||||
</GridItem>
|
||||
|
||||
{renderData.type === "direct-listing" && (
|
||||
<>
|
||||
<GridItem colSpan={3}>
|
||||
<Heading size="label.md">Price</Heading>
|
||||
<p className="font-bold">Price</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<Text fontFamily="mono" size="body.md">
|
||||
{renderData.currencyValuePerToken.displayValue}{" "}
|
||||
{renderData.currencyValuePerToken.symbol}
|
||||
</Text>
|
||||
{renderData.currencyValuePerToken.displayValue}{" "}
|
||||
{renderData.currencyValuePerToken.symbol}
|
||||
</GridItem>
|
||||
</>
|
||||
)}
|
||||
@@ -153,16 +146,13 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
{data?.asset.metadata.properties ? (
|
||||
<Card as={Flex} flexDir="column" gap={4}>
|
||||
<Heading size="label.md">Attributes</Heading>
|
||||
<CodeBlock
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<p className="font-bold">Attributes</p>
|
||||
<CodeClient
|
||||
code={
|
||||
JSON.stringify(data.asset.metadata.properties, null, 2) || ""
|
||||
}
|
||||
language="json"
|
||||
canCopy={false}
|
||||
wrap={false}
|
||||
overflow="auto"
|
||||
lang="json"
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
+17
-17
@@ -1,7 +1,6 @@
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
IconButton,
|
||||
Select,
|
||||
Skeleton,
|
||||
@@ -38,7 +37,6 @@ import type {
|
||||
EnglishAuction,
|
||||
} from "thirdweb/extensions/marketplace";
|
||||
import { min } from "thirdweb/utils";
|
||||
import { Button, Text } from "tw-components";
|
||||
import { ListingDrawer } from "./listing-drawer";
|
||||
import { LISTING_STATUS } from "./types";
|
||||
|
||||
@@ -73,9 +71,9 @@ const tableColumns: Column<DirectListing | EnglishAuction>[] = [
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: ({ cell }: { cell: Cell<any, any> }) => {
|
||||
return (
|
||||
<Text size="label.md" whiteSpace="nowrap">
|
||||
<p className="whitespace-nowrap text-muted-foreground">
|
||||
{cell.value.displayValue} {cell.value.symbol}
|
||||
</Text>
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -182,21 +180,23 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<Flex gap={4} direction="column">
|
||||
<ButtonGroup size="sm" variant="outline" isAttached>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
onClick={() => setListingsToShow("all")}
|
||||
variant={listingsToShow === "all" ? "solid" : "outline"}
|
||||
variant={listingsToShow === "all" ? "default" : "outline"}
|
||||
className="w-18 rounded-r-none"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setListingsToShow("valid")}
|
||||
variant={listingsToShow === "valid" ? "solid" : "outline"}
|
||||
variant={listingsToShow === "valid" ? "default" : "outline"}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
Valid
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<TableContainer maxW="100%" className="relative">
|
||||
{((listingsToShow === "all" && getAllQueryResult.isFetching) ||
|
||||
(listingsToShow === "valid" && getValidQueryResult.isFetching)) && (
|
||||
@@ -226,9 +226,9 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={columnIndex}
|
||||
>
|
||||
<Text as="label" size="label.sm" color="faded">
|
||||
<p className="text-muted-foreground">
|
||||
{column.render("Header")}
|
||||
</Text>
|
||||
</p>
|
||||
</Th>
|
||||
))}
|
||||
{/* // Need to add an empty header for the drawer button */}
|
||||
@@ -273,7 +273,7 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Flex gap={2} direction="row" align="center">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage || totalCountQuery.isPending}
|
||||
aria-label="first page"
|
||||
@@ -286,7 +286,7 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<Text whiteSpace="nowrap">
|
||||
<p className="whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<Skeleton
|
||||
as="span"
|
||||
@@ -295,7 +295,7 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
>
|
||||
<strong>{pageCount}</strong>
|
||||
</Skeleton>
|
||||
</Text>
|
||||
</p>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage || totalCountQuery.isPending}
|
||||
aria-label="next page"
|
||||
@@ -322,8 +322,8 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { Heading } from "tw-components";
|
||||
import { CreateListingButton } from "../components/list-button";
|
||||
import { DirectListingsTable } from "./components/table";
|
||||
|
||||
@@ -15,7 +14,7 @@ export const ContractDirectListingsPage: React.FC<
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Heading size="title.sm">Contract Listings</Heading>
|
||||
<p className="text-lg">Contract Listings</p>
|
||||
<div className="flex flex-row gap-4">
|
||||
<CreateListingButton
|
||||
contract={contract}
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { ContractDirectListingsPage } from "./ContractDirectListingsPage";
|
||||
import { ContractDirectListingsPageClient } from "./ContractDirectListingsPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
@@ -26,7 +27,7 @@ export default async function Page(props: {
|
||||
);
|
||||
|
||||
if (!isDirectListingSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractDirectListingsPage contract={info.contract} />;
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { Heading } from "tw-components";
|
||||
import { CreateListingButton } from "../components/list-button";
|
||||
import { EnglishAuctionsTable } from "./components/table";
|
||||
|
||||
@@ -15,7 +14,7 @@ export const ContractEnglishAuctionsPage: React.FC<
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Heading size="title.sm">Contract Auctions</Heading>
|
||||
<p className="text-lg">Contract Auctions</p>
|
||||
<div className="flex flex-row gap-4">
|
||||
<CreateListingButton
|
||||
contract={contract}
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { ContractEnglishAuctionsPage } from "./ContractEnglishAuctionsPage";
|
||||
import { ContractEnglishAuctionsPageClient } from "./ContractEnglishAuctionsPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
@@ -26,7 +27,7 @@ export default async function Page(props: {
|
||||
);
|
||||
|
||||
if (!isEnglishAuctionSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractEnglishAuctionsPage contract={info.contract} />;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Input } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
+6
-4
@@ -1,4 +1,4 @@
|
||||
import { Link } from "tw-components";
|
||||
import Link from "next/link";
|
||||
import { QuantityInputWithUnlimited } from "../../quantity-input-with-unlimited";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
@@ -41,8 +41,8 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
|
||||
Limits are set per wallets and not per user, sophisticated actors
|
||||
could get around wallet restrictions.{" "}
|
||||
<Link
|
||||
isExternal
|
||||
color="blue.500"
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
href="https://portal.thirdweb.com/contracts/design/Drop#sybil-attacks"
|
||||
>
|
||||
Learn more
|
||||
@@ -54,7 +54,9 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
|
||||
<QuantityInputWithUnlimited
|
||||
isRequired
|
||||
decimals={tokenDecimals}
|
||||
isDisabled={dropType === "specific" || formDisabled}
|
||||
isDisabled={
|
||||
dropType === "specific" || formDisabled || (isErc20 && !tokenDecimals)
|
||||
}
|
||||
value={field?.maxClaimablePerWallet?.toString() || "0"}
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export const MaxClaimableSupplyInput: React.FC = () => {
|
||||
>
|
||||
<QuantityInputWithUnlimited
|
||||
isRequired
|
||||
isDisabled={formDisabled}
|
||||
isDisabled={formDisabled || (isErc20 && !tokenDecimals)}
|
||||
decimals={tokenDecimals}
|
||||
value={field.maxClaimableSupply?.toString() || "0"}
|
||||
onChange={(value) =>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { Input } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
@@ -18,7 +18,7 @@ export const PhaseNameInput: React.FC = () => {
|
||||
helperText="This does not affect how your claim phase functions and is for organizational purposes only."
|
||||
>
|
||||
<Input
|
||||
isDisabled={formDisabled}
|
||||
disabled={formDisabled}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
placeholder={inputPlaceholder}
|
||||
|
||||
+2
-9
@@ -1,11 +1,6 @@
|
||||
import { Flex, FormControl } from "@chakra-ui/react";
|
||||
import type { FieldError } from "react-hook-form";
|
||||
import {
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Heading,
|
||||
} from "tw-components";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import type { ComponentWithChildren } from "types/component-with-children";
|
||||
import { useClaimConditionsFormContext } from ".";
|
||||
|
||||
@@ -22,9 +17,7 @@ export const CustomFormControl: ComponentWithChildren<
|
||||
return (
|
||||
<FormControl isDisabled={props.disabled} isInvalid={!!props.error}>
|
||||
{/* label */}
|
||||
<Heading as={FormLabel} size="label.md">
|
||||
{props.label}
|
||||
</Heading>
|
||||
<FormLabel className="font-bold">{props.label}</FormLabel>
|
||||
|
||||
{/* input */}
|
||||
{props.children}
|
||||
|
||||
+71
-6
@@ -2,6 +2,7 @@ import {
|
||||
type BaseTransactionOptions,
|
||||
type ThirdwebClient,
|
||||
toTokens,
|
||||
toUnits,
|
||||
} from "thirdweb";
|
||||
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
|
||||
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
|
||||
@@ -42,6 +43,7 @@ type CombinedClaimCondition = Prettify<
|
||||
type Options =
|
||||
| {
|
||||
type: "erc20";
|
||||
decimals?: number;
|
||||
}
|
||||
| {
|
||||
type: "erc721";
|
||||
@@ -91,12 +93,24 @@ export async function getClaimPhasesInLegacyFormat(
|
||||
startTime: new Date(Number(condition.startTimestamp * 1000n)),
|
||||
currencyAddress: condition.currency,
|
||||
price: condition.pricePerToken,
|
||||
maxClaimableSupply: toUnlimited(condition.maxClaimableSupply),
|
||||
maxClaimableSupply:
|
||||
options.type === "erc20"
|
||||
? convertERC20ValueToDisplayValue(
|
||||
condition.maxClaimableSupply,
|
||||
options.decimals,
|
||||
)
|
||||
: toUnlimited(condition.maxClaimableSupply),
|
||||
currencyMetadata,
|
||||
currentMintSupply: (
|
||||
condition.maxClaimableSupply - condition.supplyClaimed
|
||||
).toString(),
|
||||
maxClaimablePerWallet: toUnlimited(condition.quantityLimitPerWallet),
|
||||
maxClaimablePerWallet:
|
||||
options.type === "erc20"
|
||||
? convertERC20ValueToDisplayValue(
|
||||
condition.quantityLimitPerWallet,
|
||||
options.decimals,
|
||||
)
|
||||
: toUnlimited(condition.quantityLimitPerWallet),
|
||||
merkleRootHash: condition.merkleRoot,
|
||||
metadata,
|
||||
snapshot,
|
||||
@@ -114,8 +128,20 @@ export function setClaimPhasesTx(
|
||||
const phases = rawPhases.map((phase) => {
|
||||
return {
|
||||
startTime: toDate(phase.startTime),
|
||||
maxClaimableSupply: toBigInt(phase.maxClaimableSupply),
|
||||
maxClaimablePerWallet: toBigInt(phase.maxClaimablePerWallet),
|
||||
maxClaimableSupply:
|
||||
baseOptions.type === "erc20"
|
||||
? convertERC20ValueToWei(
|
||||
phase.maxClaimableSupply,
|
||||
baseOptions.decimals,
|
||||
)
|
||||
: toBigInt(phase.maxClaimableSupply),
|
||||
maxClaimablePerWallet:
|
||||
baseOptions.type === "erc20"
|
||||
? convertERC20ValueToWei(
|
||||
phase.maxClaimablePerWallet,
|
||||
baseOptions.decimals,
|
||||
)
|
||||
: toBigInt(phase.maxClaimablePerWallet),
|
||||
merkleRootHash: phase.merkleRootHash as string | undefined,
|
||||
overrideList: phase.snapshot?.length
|
||||
? snapshotToOverrides(phase.snapshot)
|
||||
@@ -175,7 +201,7 @@ function toDate(timestamp: number | Date | undefined) {
|
||||
}
|
||||
return new Date(timestamp);
|
||||
}
|
||||
function toBigInt(value: string | number | undefined) {
|
||||
function toBigInt(value: string | number | undefined): bigint | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -185,8 +211,47 @@ function toBigInt(value: string | number | undefined) {
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
// The input from client-side is non-wei, but the extension is expecting value in wei
|
||||
// so we need to convert it using toUnits
|
||||
function convertERC20ValueToWei(
|
||||
value: string | number | undefined,
|
||||
decimals?: number,
|
||||
) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === "unlimited") {
|
||||
return maxUint256;
|
||||
}
|
||||
// The ERC20Claim condition extension in v5 does not convert to wei for us
|
||||
// so we have to, manually
|
||||
if (decimals) {
|
||||
return toUnits(value.toString(), decimals);
|
||||
}
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
// This value we get from ERC20Ext.getClaimConditions is in wei
|
||||
// so we have to convert it using toTokens for readability, and for users to update
|
||||
// (when user updates this value, we convert it back to wei - see `function setClaimPhasesTx`)
|
||||
function convertERC20ValueToDisplayValue(
|
||||
value: bigint,
|
||||
decimals?: number,
|
||||
): string {
|
||||
if (value === maxUint256) {
|
||||
return "unlimited";
|
||||
}
|
||||
if (decimals) {
|
||||
return toTokens(value, decimals);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
function toUnlimited(value: bigint) {
|
||||
return value === maxUint256 ? "unlimited" : value.toString();
|
||||
if (value === maxUint256) {
|
||||
return "unlimited";
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
async function fetchSnapshot(
|
||||
|
||||
+30
-11
@@ -13,6 +13,7 @@ import {
|
||||
Flex,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
useFieldArray,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebContract,
|
||||
@@ -38,7 +40,7 @@ import {
|
||||
useSendAndConfirmTransaction,
|
||||
} from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Button, Heading, MenuItem, Text } from "tw-components";
|
||||
import { Button, Heading, Text } from "tw-components";
|
||||
import * as z from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
@@ -152,7 +154,7 @@ interface ClaimsConditionFormContextData {
|
||||
field: ControlledField;
|
||||
phaseIndex: number;
|
||||
formDisabled: boolean;
|
||||
tokenDecimals: number;
|
||||
tokenDecimals: number | undefined;
|
||||
isMultiPhase: boolean;
|
||||
isActive: boolean;
|
||||
dropType: DropType;
|
||||
@@ -210,7 +212,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
enabled: isErc20,
|
||||
},
|
||||
});
|
||||
const tokenDecimalsData = tokenDecimals.data ?? 0;
|
||||
const saveClaimPhaseNotification = useTxNotifications(
|
||||
"Saved claim phases",
|
||||
"Failed to save claim phases",
|
||||
@@ -219,7 +220,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
const claimConditionsQuery = useReadContract(getClaimPhasesInLegacyFormat, {
|
||||
contract,
|
||||
...(isErc20
|
||||
? { type: "erc20" }
|
||||
? { type: "erc20", decimals: tokenDecimals.data }
|
||||
: isErc721
|
||||
? { type: "erc721" }
|
||||
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
|
||||
@@ -259,7 +260,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
);
|
||||
}, [claimConditionsQuery.data, isMultiPhase]);
|
||||
|
||||
const isFetchingData = claimConditionsQuery.isFetching || sendTx.isPending;
|
||||
const isFetchingData =
|
||||
claimConditionsQuery.isFetching ||
|
||||
sendTx.isPending ||
|
||||
// Need to make sure the tokenDecimals.data is present when interacting with ERC20 claim conditions
|
||||
(isErc20 && tokenDecimals.isLoading);
|
||||
|
||||
const canEditForm = isAdmin && !isFetchingData;
|
||||
|
||||
@@ -353,13 +358,17 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
action: "set-claim-conditions",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
if (isErc20 && !tokenDecimals.data) {
|
||||
return toast.error(
|
||||
`Could not fetch token decimals for contract ${contract.address}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const tx = setClaimPhasesTx(
|
||||
{
|
||||
contract,
|
||||
...(isErc20
|
||||
? { type: "erc20" }
|
||||
? { type: "erc20", decimals: tokenDecimals.data }
|
||||
: isErc721
|
||||
? { type: "erc721" }
|
||||
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
|
||||
@@ -445,7 +454,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
};
|
||||
}, [claimConditionsQuery.data, controlledFields, isMultiPhase]);
|
||||
|
||||
if (isFetchingData) {
|
||||
if (claimConditionsQuery.isPending) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
|
||||
<Spinner className="size-10" />
|
||||
@@ -453,6 +462,15 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Do not proceed if fails to load the tokenDecimals.data - for ERC20 drop contracts specifically
|
||||
if (isErc20 && tokenDecimals.data === undefined) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
|
||||
Failed to load token decimals
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex onSubmit={handleFormSubmit} direction="column" as="form" gap={10}>
|
||||
@@ -492,8 +510,9 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
<Fragment key={`snapshot_${field.id}_${index}`}>
|
||||
<SnapshotUpload
|
||||
dropType={dropType}
|
||||
isOpen={openSnapshotIndex === index}
|
||||
onClose={() => setOpenSnapshotIndex(-1)}
|
||||
snapshotIndex={openSnapshotIndex}
|
||||
index={index}
|
||||
setOpenSnapshotIndex={setOpenSnapshotIndex}
|
||||
value={snapshotValue}
|
||||
setSnapshot={(snapshot) =>
|
||||
form.setValue(`phases.${index}.snapshot`, snapshot)
|
||||
@@ -508,7 +527,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
phaseIndex: index,
|
||||
formDisabled: !canEditForm,
|
||||
isErc20,
|
||||
tokenDecimals: tokenDecimalsData,
|
||||
tokenDecimals: tokenDecimals.data,
|
||||
dropType,
|
||||
setOpenSnapshotIndex,
|
||||
isAdmin,
|
||||
|
||||
+98
-100
@@ -1,8 +1,9 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdminOnly } from "@3rdweb-sdk/react/components/roles/admin-only";
|
||||
import { Flex, SimpleGrid } from "@chakra-ui/react";
|
||||
import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { Badge, Button, Card, Heading, Text } from "tw-components";
|
||||
import { ClaimConditionTypeData, useClaimConditionsFormContext } from ".";
|
||||
import { PricePreview } from "../price-preview";
|
||||
import { ClaimPriceInput } from "./Inputs/ClaimPriceInput";
|
||||
@@ -41,117 +42,114 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card position="relative" p={8}>
|
||||
<Flex direction="column" gap={8}>
|
||||
<Flex
|
||||
align="flex-start"
|
||||
justify="space-between"
|
||||
position="absolute"
|
||||
top="10px"
|
||||
right="10px"
|
||||
gap={1}
|
||||
<Card className="relative flex flex-col gap-8 p-8">
|
||||
<div className="absolute top-3 right-3 flex flex-row items-start justify-between gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleEditing}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{field.isEditing ? "Collapse" : isAdmin ? "Edit" : "See Phase"}
|
||||
{field.isEditing ? (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<AdminOnly contract={contract}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleEditing}
|
||||
onClick={onRemove}
|
||||
disabled={isPending}
|
||||
size="sm"
|
||||
rightIcon={
|
||||
field.isEditing ? (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
)
|
||||
}
|
||||
className="gap-2 text-red-300"
|
||||
>
|
||||
{field.isEditing ? "Collapse" : isAdmin ? "Edit" : "See Phase"}
|
||||
Remove <XIcon className="size-4" />
|
||||
</Button>
|
||||
<AdminOnly contract={contract}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
isDisabled={isPending}
|
||||
colorScheme="red"
|
||||
size="sm"
|
||||
rightIcon={<XIcon className="size-4" />}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</AdminOnly>
|
||||
</Flex>
|
||||
</AdminOnly>
|
||||
</div>
|
||||
|
||||
<Flex flexDir="column" gap={2} mt={{ base: 4, md: 0 }}>
|
||||
<Flex gap={3} alignItems="center">
|
||||
<Heading>{ClaimConditionTypeData[claimConditionType].name}</Heading>
|
||||
{isActive && (
|
||||
<Badge colorScheme="green" borderRadius="lg" p={1.5}>
|
||||
Currently active
|
||||
</Badge>
|
||||
<div className="mt-4 flex flex-col gap-2 md:mt-0">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<p className="font-bold text-lg text-muted-foreground">
|
||||
{ClaimConditionTypeData[claimConditionType].name}
|
||||
</p>
|
||||
{isActive && (
|
||||
<Badge variant="success" className="rounded-lg p-1.5">
|
||||
Currently active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{ClaimConditionTypeData[claimConditionType].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!field.isEditing ? (
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<div className="flex flex-col">
|
||||
<p className="font-bold text-muted-foreground">Phase start</p>
|
||||
<p className="text-muted-foreground">
|
||||
{field.startTime?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-bold text-muted-foreground">
|
||||
{isErc20 ? "Tokens" : "NFTs"} to drop
|
||||
</p>
|
||||
<p className="text-muted-foreground capitalize">
|
||||
{field.maxClaimableSupply}
|
||||
</p>
|
||||
</div>
|
||||
<PricePreview
|
||||
price={field.price}
|
||||
currencyAddress={field.currencyAddress}
|
||||
contractChainId={contract.chain.id}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-bold text-muted-foreground">Limit per wallet</p>
|
||||
{claimConditionType === "specific" ? (
|
||||
<p>Set in the snapshot</p>
|
||||
) : claimConditionType === "creator" ? (
|
||||
<p>Unlimited</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground capitalize">
|
||||
{field.maxClaimablePerWallet}
|
||||
</p>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CustomFormGroup>
|
||||
{/* Phase Name Input / Form Title */}
|
||||
{isMultiPhase ? <PhaseNameInput /> : null}
|
||||
<PhaseStartTimeInput />
|
||||
</CustomFormGroup>
|
||||
|
||||
<Text>{ClaimConditionTypeData[claimConditionType].description}</Text>
|
||||
</Flex>
|
||||
<CreatorInput
|
||||
creatorAddress={
|
||||
(field.snapshot?.[0] as { address: string })?.address
|
||||
}
|
||||
/>
|
||||
|
||||
{!field.isEditing ? (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={2}>
|
||||
<div className="flex flex-col">
|
||||
<Text fontWeight="bold">Phase start</Text>
|
||||
<Text>{field.startTime?.toLocaleString()}</Text>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Text fontWeight="bold">
|
||||
{isErc20 ? "Tokens" : "NFTs"} to drop
|
||||
</Text>
|
||||
<Text textTransform="capitalize">{field.maxClaimableSupply}</Text>
|
||||
</div>
|
||||
<PricePreview
|
||||
price={field.price}
|
||||
currencyAddress={field.currencyAddress}
|
||||
contractChainId={contract.chain.id}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Text fontWeight="bold">Limit per wallet</Text>
|
||||
{claimConditionType === "specific" ? (
|
||||
<Text>Set in the snapshot</Text>
|
||||
) : claimConditionType === "creator" ? (
|
||||
<Text>Unlimited</Text>
|
||||
) : (
|
||||
<Text textTransform="capitalize">
|
||||
{field.maxClaimablePerWallet}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<>
|
||||
<CustomFormGroup>
|
||||
<MaxClaimableSupplyInput />
|
||||
<ClaimPriceInput contractChainId={contract.chain.id} />
|
||||
</CustomFormGroup>
|
||||
|
||||
{claimConditionType === "specific" ||
|
||||
claimConditionType === "creator" ? null : (
|
||||
<CustomFormGroup>
|
||||
{/* Phase Name Input / Form Title */}
|
||||
{isMultiPhase ? <PhaseNameInput /> : null}
|
||||
<PhaseStartTimeInput />
|
||||
<MaxClaimablePerWalletInput />
|
||||
</CustomFormGroup>
|
||||
)}
|
||||
|
||||
<CreatorInput
|
||||
creatorAddress={
|
||||
(field.snapshot?.[0] as { address: string })?.address
|
||||
}
|
||||
/>
|
||||
|
||||
<CustomFormGroup>
|
||||
<MaxClaimableSupplyInput />
|
||||
<ClaimPriceInput contractChainId={contract.chain.id} />
|
||||
</CustomFormGroup>
|
||||
|
||||
{claimConditionType === "specific" ||
|
||||
claimConditionType === "creator" ? null : (
|
||||
<CustomFormGroup>
|
||||
<MaxClaimablePerWalletInput />
|
||||
</CustomFormGroup>
|
||||
)}
|
||||
|
||||
<ClaimerSelection />
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
<ClaimerSelection />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
+19
-25
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ClaimConditionsForm } from "./claim-conditions-form/index";
|
||||
|
||||
@@ -17,31 +16,26 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
|
||||
isERC20,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-8">
|
||||
<Flex p={0} position="relative">
|
||||
<Flex direction="column" gap={6} w="full">
|
||||
{/* Info */}
|
||||
<section>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
Set Claim Conditions
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Control when the {isERC20 ? "tokens" : "NFTs"} get dropped, how
|
||||
much they cost, and more.
|
||||
</p>
|
||||
</section>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<section>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
Set Claim Conditions
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Control when the {isERC20 ? "tokens" : "NFTs"} get dropped, how much
|
||||
they cost, and more.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Set Claim Conditions */}
|
||||
<ClaimConditionsForm
|
||||
isErc20={isERC20}
|
||||
contract={contract}
|
||||
tokenId={tokenId}
|
||||
isColumn={isColumn}
|
||||
// always multi phase!
|
||||
isMultiPhase={true}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{/* Set Claim Conditions */}
|
||||
<ClaimConditionsForm
|
||||
isErc20={isERC20}
|
||||
contract={contract}
|
||||
tokenId={tokenId}
|
||||
isColumn={isColumn}
|
||||
// always multi phase!
|
||||
isMultiPhase={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+5
-6
@@ -1,6 +1,5 @@
|
||||
import { CURRENCIES } from "constants/currencies";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { Text } from "tw-components";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
import { isAddressZero } from "utils/zeroAddress";
|
||||
|
||||
@@ -25,19 +24,19 @@ export const PricePreview: React.FC<PricePreviewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Text fontWeight="bold">Default price</Text>
|
||||
<div className="flex flex-col text-muted-foreground">
|
||||
<p className="font-bold">Default price</p>
|
||||
{Number(price) === 0 ? (
|
||||
<Text>Free</Text>
|
||||
<p>Free</p>
|
||||
) : (
|
||||
<Text>
|
||||
<p>
|
||||
{price}{" "}
|
||||
{foundCurrency
|
||||
? foundCurrency.symbol
|
||||
: isAddressZero(currencyAddress || "")
|
||||
? chain?.nativeCurrency.symbol || "(Native Token)"
|
||||
: `(${shortenIfAddress(currencyAddress)})`}
|
||||
</Text>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+21
-28
@@ -1,18 +1,14 @@
|
||||
import {
|
||||
Input,
|
||||
InputGroup,
|
||||
type InputProps,
|
||||
InputRightElement,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "tw-components";
|
||||
|
||||
interface QuantityInputWithUnlimitedProps
|
||||
extends Omit<InputProps, "onChange" | "value" | "onBlur" | "max" | "min"> {
|
||||
interface QuantityInputWithUnlimitedProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
hideMaxButton?: true;
|
||||
decimals?: number;
|
||||
isDisabled: boolean;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
export const QuantityInputWithUnlimited: React.FC<
|
||||
@@ -24,7 +20,6 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
isDisabled,
|
||||
isRequired,
|
||||
decimals,
|
||||
...restInputProps
|
||||
}) => {
|
||||
const [stringValue, setStringValue] = useState<string>(
|
||||
Number.isNaN(Number(value)) ? "0" : value.toString(),
|
||||
@@ -50,10 +45,10 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
};
|
||||
|
||||
return (
|
||||
<InputGroup {...restInputProps}>
|
||||
<div className="flex flex-row items-center rounded-md border border-border">
|
||||
<Input
|
||||
isRequired={isRequired}
|
||||
isDisabled={decimals === undefined || isDisabled}
|
||||
required={isRequired}
|
||||
disabled={isDisabled}
|
||||
value={stringValue === "unlimited" ? "Unlimited" : stringValue}
|
||||
onChange={(e) => updateValue(e.currentTarget.value)}
|
||||
onBlur={() => {
|
||||
@@ -65,23 +60,21 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
setStringValue("0");
|
||||
}
|
||||
}}
|
||||
className="border-none"
|
||||
/>
|
||||
{hideMaxButton ? null : (
|
||||
<InputRightElement w="auto">
|
||||
<Button
|
||||
isDisabled={decimals === undefined || isDisabled}
|
||||
colorScheme="primary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
mr={1}
|
||||
onClick={() => {
|
||||
updateValue("unlimited");
|
||||
}}
|
||||
>
|
||||
Unlimited
|
||||
</Button>
|
||||
</InputRightElement>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mr-1 text-primary"
|
||||
onClick={() => {
|
||||
updateValue("unlimited");
|
||||
}}
|
||||
>
|
||||
Unlimited
|
||||
</Button>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+276
-546
@@ -1,43 +1,21 @@
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
IconButton,
|
||||
Link,
|
||||
ListItem,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tooltip,
|
||||
Tr,
|
||||
UnorderedList,
|
||||
} from "@chakra-ui/react";
|
||||
import { Logo } from "components/logo";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
DownloadIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type DropzoneOptions, useDropzone } from "react-dropzone";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import { Button, Drawer, Heading, Text } from "tw-components";
|
||||
import { csvMimeTypes } from "utils/batch";
|
||||
import { Box, Flex, Link } from "@chakra-ui/react";
|
||||
import { useCsvUpload } from "hooks/useCsvUpload";
|
||||
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useRef } from "react";
|
||||
import type { Column } from "react-table";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { CsvDataTable } from "../csv-data-table";
|
||||
|
||||
interface SnapshotAddressInput {
|
||||
address: string;
|
||||
@@ -48,324 +26,271 @@ interface SnapshotAddressInput {
|
||||
}
|
||||
interface SnapshotUploadProps {
|
||||
setSnapshot: (snapshot: SnapshotAddressInput[]) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value?:
|
||||
| {
|
||||
address: string;
|
||||
maxClaimable: string;
|
||||
price?: string | undefined;
|
||||
currencyAddress?: string | undefined;
|
||||
}[]
|
||||
| null;
|
||||
snapshotIndex: number;
|
||||
setOpenSnapshotIndex: Dispatch<SetStateAction<number>>;
|
||||
index: number;
|
||||
dropType: "specific" | "any" | "overrides";
|
||||
isDisabled: boolean;
|
||||
value?: SnapshotAddressInput[] | undefined;
|
||||
}
|
||||
|
||||
const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
|
||||
return items
|
||||
.map(({ address, maxClaimable, price, currencyAddress }) => ({
|
||||
address: (address || "").trim(),
|
||||
maxClaimable: (maxClaimable || "0").trim().toLowerCase(),
|
||||
price: (price || "").trim() || undefined,
|
||||
currencyAddress: (currencyAddress || "").trim() || undefined,
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
};
|
||||
|
||||
export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
|
||||
setSnapshot,
|
||||
isOpen,
|
||||
onClose,
|
||||
value,
|
||||
snapshotIndex,
|
||||
index,
|
||||
setOpenSnapshotIndex,
|
||||
dropType,
|
||||
isDisabled,
|
||||
value,
|
||||
}) => {
|
||||
const client = useThirdwebClient();
|
||||
const [validSnapshot, setValidSnapshot] = useState<SnapshotAddressInput[]>(
|
||||
value || [],
|
||||
);
|
||||
const [snapshotData, setSnapshotData] = useState<SnapshotAddressInput[]>([]);
|
||||
const [noCsv, setNoCsv] = useState(false);
|
||||
const [invalidFound, setInvalidFound] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValidSnapshot([]);
|
||||
setNoCsv(false);
|
||||
}, []);
|
||||
|
||||
const _onClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const onDrop = useCallback<Required<DropzoneOptions>["onDrop"]>(
|
||||
(acceptedFiles) => {
|
||||
setNoCsv(false);
|
||||
|
||||
const csv = acceptedFiles.find(
|
||||
(f) => csvMimeTypes.includes(f.type) || f.name?.endsWith(".csv"),
|
||||
);
|
||||
if (!csv) {
|
||||
console.error(
|
||||
"No valid CSV file found, make sure you have an address column.",
|
||||
);
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Papa.parse(csv, {
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
const data: SnapshotAddressInput[] = (
|
||||
results.data as SnapshotAddressInput[]
|
||||
)
|
||||
.map(({ address, maxClaimable, price, currencyAddress }) => ({
|
||||
address: (address || "").trim(),
|
||||
maxClaimable: (maxClaimable || "0").trim().toLowerCase(),
|
||||
price: (price || "").trim() || undefined,
|
||||
currencyAddress: (currencyAddress || "").trim() || undefined,
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
|
||||
if (!data[0]?.address) {
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidSnapshot(data);
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// FIXME: this can be a mutation or query instead!
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (validSnapshot.length === 0) {
|
||||
return setSnapshotData([]);
|
||||
}
|
||||
|
||||
const normalizeAddresses = async (snapshot: SnapshotAddressInput[]) => {
|
||||
const normalized = await Promise.all(
|
||||
snapshot.map(async ({ address, ...rest }) => {
|
||||
let isValid = true;
|
||||
let resolvedAddress = address;
|
||||
|
||||
try {
|
||||
resolvedAddress = isAddress(address)
|
||||
? address
|
||||
: await resolveAddress({ client, name: address });
|
||||
isValid = !!resolvedAddress;
|
||||
} catch {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
resolvedAddress,
|
||||
isValid,
|
||||
...rest,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const seen = new Set();
|
||||
const filteredData = normalized.filter((el) => {
|
||||
const duplicate = seen.has(el.resolvedAddress);
|
||||
seen.add(el.resolvedAddress);
|
||||
return !duplicate;
|
||||
});
|
||||
|
||||
const valid = filteredData.filter(({ isValid }) => isValid);
|
||||
const invalid = filteredData.filter(({ isValid }) => !isValid);
|
||||
|
||||
if (invalid?.length > 0) {
|
||||
setInvalidFound(true);
|
||||
}
|
||||
const ordered = [...invalid, ...valid];
|
||||
setSnapshotData(ordered);
|
||||
};
|
||||
normalizeAddresses(validSnapshot);
|
||||
}, [validSnapshot, client]);
|
||||
|
||||
const removeInvalid = useCallback(() => {
|
||||
const filteredData = snapshotData.filter(({ isValid }) => isValid);
|
||||
setValidSnapshot(filteredData);
|
||||
setInvalidFound(false);
|
||||
}, [snapshotData]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
});
|
||||
const {
|
||||
normalizeQuery,
|
||||
getInputProps,
|
||||
getRootProps,
|
||||
isDragActive,
|
||||
rawData,
|
||||
noCsv,
|
||||
reset,
|
||||
removeInvalid,
|
||||
} = useCsvUpload<SnapshotAddressInput>({ csvParser, defaultRawData: value });
|
||||
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onSave = () => {
|
||||
setSnapshot(snapshotData);
|
||||
onClose();
|
||||
setSnapshot(normalizeQuery.data.result);
|
||||
setOpenSnapshotIndex(-1);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
Header: "Address",
|
||||
accessor: ({ address, isValid }) => {
|
||||
if (isValid) {
|
||||
return address;
|
||||
}
|
||||
return (
|
||||
<ToolTipLabel
|
||||
label={
|
||||
address === ZERO_ADDRESS
|
||||
? "Cannot send tokens to ZERO_ADDRESS"
|
||||
: address.startsWith("0x")
|
||||
? "Address is not valid"
|
||||
: "Address couldn't be resolved"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-4 text-red-500" />
|
||||
<div className="cursor-default font-bold text-red-500">
|
||||
{address}
|
||||
</div>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Max claimable",
|
||||
accessor: ({ maxClaimable }) => {
|
||||
return maxClaimable === "0" || !maxClaimable
|
||||
? "Default"
|
||||
: maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: maxClaimable;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Price",
|
||||
accessor: ({ price }) => {
|
||||
return price === "0"
|
||||
? "Free"
|
||||
: !price || price === "unlimited"
|
||||
? "Default"
|
||||
: price;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Currency Address",
|
||||
accessor: ({ currencyAddress }) => {
|
||||
return currencyAddress ===
|
||||
"0x0000000000000000000000000000000000000000" || !currencyAddress
|
||||
? "Default"
|
||||
: currencyAddress;
|
||||
},
|
||||
},
|
||||
] as Column<SnapshotAddressInput>[];
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpenSnapshotIndex(isOpen ? snapshotIndex : -1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
allowPinchZoom
|
||||
preserveScrollBarGap
|
||||
size="xl"
|
||||
onClose={_onClose}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<Flex direction="column" gap={6} h="100%">
|
||||
<Flex shadow="sm">
|
||||
<Container maxW="container.page">
|
||||
<Flex align="center" justify="space-between" p={4}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Logo hideWordmark />
|
||||
<Heading size="title.md">
|
||||
{validSnapshot.length ? "Edit" : "Upload"} Snapshot
|
||||
</Heading>
|
||||
</div>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
|
||||
{validSnapshot.length > 0 ? (
|
||||
<SnapshotTable portalRef={paginationPortalRef} data={snapshotData} />
|
||||
) : (
|
||||
<Flex flexGrow={1} align="center" overflow="auto">
|
||||
<Container maxW="container.page">
|
||||
<Flex gap={8} flexDir="column">
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
|
||||
noCsv ? "bg-red-200" : "bg-card",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="!m-auto flex flex-col">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
noCsv ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<Heading as={Text} size="label.md">
|
||||
Drop the files here
|
||||
</Heading>
|
||||
) : (
|
||||
<Heading
|
||||
as={Text}
|
||||
size="label.md"
|
||||
color={noCsv ? "red.500" : "gray.600"}
|
||||
>
|
||||
{noCsv
|
||||
? `No valid CSV file found, make sure your CSV includes the "address" column.`
|
||||
: "Drag & Drop a CSV file here"}
|
||||
</Heading>
|
||||
<Sheet open={snapshotIndex === index} onOpenChange={handleOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[900px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">
|
||||
{rawData.length ? "Edit" : "Upload"} Snapshot
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
{rawData.length > 0 ? (
|
||||
<CsvDataTable<SnapshotAddressInput>
|
||||
portalRef={paginationPortalRef}
|
||||
data={normalizeQuery.data.result}
|
||||
columns={columns}
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
flexGrow={1}
|
||||
align="center"
|
||||
overflow="auto"
|
||||
gap={8}
|
||||
flexDir="column"
|
||||
>
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
|
||||
noCsv ? "bg-red-200" : "bg-card",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="!m-auto flex flex-col">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
noCsv ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<p>Drop the files here</p>
|
||||
) : (
|
||||
<p className={noCsv ? "text-red-500" : "text-gray-600"}>
|
||||
{noCsv
|
||||
? `No valid CSV file found, make sure your CSV includes the "address" column.`
|
||||
: "Drag & Drop a CSV file here"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="label.md">Requirements</Heading>
|
||||
<UnorderedList spacing={1}>
|
||||
{dropType === "specific" ? (
|
||||
<>
|
||||
<Text as={ListItem}>
|
||||
Files <em>must</em> contain one .csv file with a list
|
||||
of addresses and their{" "}
|
||||
<InlineCode code="maxClaimable" />. (amount each
|
||||
wallet is allowed to claim)
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides as
|
||||
well. This lets you override the currency and price
|
||||
you would like to charge per wallet you specified
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text as={ListItem}>
|
||||
Files <em>must</em> contain one .csv file with a list
|
||||
of addresses.
|
||||
<br />
|
||||
<Link download color="blue.500" href="/snapshot.csv">
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
You may optionally add a{" "}
|
||||
<InlineCode code="maxClaimable" />
|
||||
column override. (amount each wallet is allowed to
|
||||
claim) If not specified, the default value is the one
|
||||
you have set on your claim phase.
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides. This
|
||||
lets you override the currency and price you would
|
||||
like to charge per wallet you specified.{" "}
|
||||
<strong>
|
||||
When defining a custom currency address, you must
|
||||
also define a price override.
|
||||
</strong>
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text as={ListItem}>
|
||||
Repeated addresses will be removed and only the first
|
||||
found will be kept.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
The limit you set is for the maximum amount of NFTs a
|
||||
wallet can claim, not how many they can receive in total.
|
||||
</Text>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex borderTop="1px solid" borderTopColor="borderColor">
|
||||
<Container maxW="container.page">
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={{ base: 0, md: 4 }}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
mt={{ base: 4, md: 0 }}
|
||||
>
|
||||
<Box ref={paginationPortalRef} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-lg">Requirements</p>
|
||||
<UnorderedList>
|
||||
{dropType === "specific" ? (
|
||||
<>
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv file with a list of
|
||||
addresses and their <InlineCode code="maxClaimable" />.
|
||||
(amount each wallet is allowed to claim)
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides as well.
|
||||
This lets you override the currency and price you would
|
||||
like to charge per wallet you specified
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv file with a list of
|
||||
addresses.
|
||||
<br />
|
||||
<Link download color="blue.500" href="/snapshot.csv">
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
You may optionally add a{" "}
|
||||
<InlineCode code="maxClaimable" />
|
||||
column override. (amount each wallet is allowed to
|
||||
claim) If not specified, the default value is the one
|
||||
you have set on your claim phase.
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides. This
|
||||
lets you override the currency and price you would like
|
||||
to charge per wallet you specified.{" "}
|
||||
<strong>
|
||||
When defining a custom currency address, you must also
|
||||
define a price override.
|
||||
</strong>
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
<li>
|
||||
Repeated addresses will be removed and only the first found
|
||||
will be kept.
|
||||
</li>
|
||||
<li>
|
||||
The limit you set is for the maximum amount of NFTs a wallet
|
||||
can claim, not how many they can receive in total.
|
||||
</li>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
</Flex>
|
||||
)}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={{ base: 0, md: 4 }}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
mt={{ base: 4, md: 0 }}
|
||||
borderTop="1px solid"
|
||||
borderTopColor="borderColor"
|
||||
>
|
||||
<Box ref={paginationPortalRef} />
|
||||
{!isDisabled && (
|
||||
<Flex
|
||||
gap={2}
|
||||
align="center"
|
||||
@@ -373,235 +298,40 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
<Button
|
||||
borderRadius="md"
|
||||
disabled={isDisabled || validSnapshot.length === 0}
|
||||
className="w-full rounded-md md:w-auto"
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{invalidFound ? (
|
||||
{normalizeQuery.data.invalidFound ? (
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
disabled={isDisabled || validSnapshot.length === 0}
|
||||
className="w-full rounded-md md:w-auto"
|
||||
variant="primary"
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
onClick={() => {
|
||||
removeInvalid();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Remove invalid
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
className="w-full rounded-md md:w-auto"
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
isDisabled={isDisabled || validSnapshot.length === 0}
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
interface SnapshotTableProps {
|
||||
data: SnapshotAddressInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const SnapshotTableColumns = [
|
||||
{
|
||||
Header: "Address",
|
||||
accessor: ({ address, isValid }) => {
|
||||
if (isValid) {
|
||||
return address;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Tooltip
|
||||
label={
|
||||
address.startsWith("0x")
|
||||
? "Address is not valid"
|
||||
: "Address couldn't be resolved"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-4 text-red-500" />
|
||||
<Text fontWeight="bold" color="red.500" cursor="default">
|
||||
{address}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Max claimable",
|
||||
accessor: ({ maxClaimable }) => {
|
||||
return maxClaimable === "0" || !maxClaimable
|
||||
? "Default"
|
||||
: maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: maxClaimable;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Price",
|
||||
accessor: ({ price }) => {
|
||||
return price === "0"
|
||||
? "Free"
|
||||
: !price || price === "unlimited"
|
||||
? "Default"
|
||||
: price;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Currency Address",
|
||||
accessor: ({ currencyAddress }) => {
|
||||
return currencyAddress === "0x0000000000000000000000000000000000000000" ||
|
||||
!currencyAddress
|
||||
? "Default"
|
||||
: currencyAddress;
|
||||
},
|
||||
},
|
||||
] as Column<SnapshotAddressInput>[];
|
||||
|
||||
const SnapshotTable: React.FC<SnapshotTableProps> = ({ data, portalRef }) => {
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
|
||||
// The rest of these things are super handy, too ;)
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageOptions,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns: SnapshotTableColumns,
|
||||
data,
|
||||
initialState: {
|
||||
pageSize: 50,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
// old package, this will be removed
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
|
||||
// Render the UI for your table
|
||||
return (
|
||||
<Flex flexGrow={1}>
|
||||
<TableContainer>
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, headerGroupIndex) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
|
||||
{headerGroup.headers.map((column, columnIndex) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Th {...column.getHeaderProps()} key={columnIndex}>
|
||||
<Text as="label" size="label.sm" color="faded">
|
||||
{column.render("Header")}
|
||||
</Text>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...row.getRowProps()} key={rowIndex}>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
return (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Portal containerRef={portalRef}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row gap-1">
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="my-auto whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
onChange={(e) => {
|
||||
setPageSize(Number.parseInt(e.target.value as string, 10));
|
||||
}}
|
||||
value={pageSize}
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { TableContainer } from "@/components/ui/table";
|
||||
import {
|
||||
IconButton,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
|
||||
interface CsvDataTableProps<T extends object> {
|
||||
data: T[];
|
||||
portalRef: React.RefObject<HTMLDivElement | null>;
|
||||
columns: Column<T>[];
|
||||
}
|
||||
/**
|
||||
* Display the data uploaded from useCsvUpload, using react-table
|
||||
*/
|
||||
export function CsvDataTable<T extends object>({
|
||||
data,
|
||||
portalRef,
|
||||
columns,
|
||||
}: CsvDataTableProps<T>) {
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
// The rest of these things are super handy, too ;)
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageOptions,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
pageSize: 50,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
// old package: this will be removed
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, headerGroupIndex) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
|
||||
{headerGroup.headers.map((column, columnIndex) => (
|
||||
<Th
|
||||
{...column.getHeaderProps()}
|
||||
border="none"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={columnIndex}
|
||||
>
|
||||
<p className="text-muted-foreground">
|
||||
{column.render("Header")}
|
||||
</p>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...row.getRowProps()} key={rowIndex}>
|
||||
{row.cells.map((cell, cellIndex) => (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{/* Only need to show the Pagination components if we have more than 25 records */}
|
||||
{data.length > 0 && (
|
||||
<Portal containerRef={portalRef}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row gap-1">
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="my-auto whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
<Select
|
||||
onChange={(e) => {
|
||||
setPageSize(Number.parseInt(e.target.value as string, 10));
|
||||
}}
|
||||
value={pageSize}
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
-8
@@ -13,14 +13,9 @@ export async function getContractPageParamsInfo(params: {
|
||||
}) {
|
||||
const contractAddress = getCheckSummedAddress(params.contractAddress);
|
||||
const chainSlugOrId = params.chain_id;
|
||||
let chainMetadata: ChainMetadata | null = null;
|
||||
try {
|
||||
chainMetadata = await fetchChain(chainSlugOrId);
|
||||
} catch {
|
||||
// move on
|
||||
}
|
||||
let chainMetadata = await fetchChain(chainSlugOrId).catch(() => null);
|
||||
|
||||
const cookieStore = cookies();
|
||||
const cookieStore = await cookies();
|
||||
const localChainStoreValue = cookieStore.get(TW_LOCAL_CHAIN_STORE)?.value;
|
||||
|
||||
if (localChainStoreValue) {
|
||||
@@ -47,7 +42,7 @@ export async function getContractPageParamsInfo(params: {
|
||||
}
|
||||
|
||||
// attempt to get the auth token
|
||||
const token = getAuthToken();
|
||||
const token = await getAuthToken();
|
||||
|
||||
const contract = getContract({
|
||||
address: contractAddress,
|
||||
|
||||
+4
-13
@@ -1,9 +1,10 @@
|
||||
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 { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Badge, Card, Heading, Text } from "tw-components";
|
||||
import { Card, Heading, Text } from "tw-components";
|
||||
|
||||
export type AccountSignerType = {
|
||||
signer: string;
|
||||
@@ -45,19 +46,9 @@ export const AccountSigner: React.FC<AccountSignerProps> = ({
|
||||
<WalletAddress shortenAddress={isMobile} address={signer} />
|
||||
</Heading>
|
||||
<div className="flex flex-row gap-2">
|
||||
{isAdmin ? (
|
||||
<Badge borderRadius="lg" p={1.5}>
|
||||
Admin Key
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge borderRadius="lg" p={1.5}>
|
||||
Scoped key
|
||||
</Badge>
|
||||
)}
|
||||
{isAdmin ? <Badge>Admin Key</Badge> : <Badge>Scoped key</Badge>}
|
||||
{signer === address && (
|
||||
<Badge colorScheme="green" borderRadius="lg" p={1.5}>
|
||||
Currently connected
|
||||
</Badge>
|
||||
<Badge variant="success">Currently connected</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { AccountSignersClient } from "./AccountSigners.client";
|
||||
import { AccountSigners } from "./components/account-signers";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
@@ -27,7 +28,7 @@ export default async function Page(props: {
|
||||
await getContractPageMetadata(contract);
|
||||
|
||||
if (!isAccountPermissionsSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <AccountSigners contract={contract} />;
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { AccountPage } from "./AccountPage";
|
||||
import { AccountPageClient } from "./AccountPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
@@ -27,7 +28,7 @@ export default async function Page(props: {
|
||||
const { isAccount } = await getContractPageMetadata(contract);
|
||||
|
||||
if (!isAccount) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <AccountPage contract={contract} chainMetadata={chainMetadata} />;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user