This commit is contained in:
Jonas Daniels
2025-06-20 16:14:38 -07:00
committed by GitHub
parent 9fec4ccec4
commit 997beeac3a
4341 changed files with 66909 additions and 70167 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
- name: Setup Biome
uses: biomejs/setup-biome@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1
with:
version: latest
version: 2.0.0
- run: pnpm lint
+26 -27
View File
@@ -1,11 +1,32 @@
{
"editor.formatOnSave": true,
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports.biome": "explicit",
"quickfix.biome": "explicit"
"quickfix.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"eslint.workingDirectories": [
{ "pattern": "./packages/*/" },
{ "pattern": "./apps/*/" }
],
"typescript.preferences.autoImportFileExcludePatterns": [
"./packages/thirdweb/src/exports"
],
@@ -15,27 +36,5 @@
"next/dist",
"^lucide-react/dist/lucide-react.suffixed$"
],
"typescript.tsdk": "node_modules/typescript/lib",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"eslint.workingDirectories": [
{ "pattern": "./packages/*/" },
{ "pattern": "./apps/*/" }
]
"typescript.tsdk": "node_modules/typescript/lib"
}
+122 -114
View File
@@ -1,51 +1,97 @@
module.exports = {
env: {
browser: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@next/next/recommended",
"plugin:storybook/recommended",
],
overrides: [
// disable restricted imports in tw-components
{
files: "src/tw-components/**/*",
rules: {
"no-restricted-imports": ["off"],
},
},
// allow direct PostHog imports inside analytics helpers
{
files: "src/@/analytics/**/*",
rules: {
"no-restricted-imports": ["off"],
},
},
// enable rule specifically for TypeScript files
{
files: ["*.ts", "*.tsx"],
rules: {
"@typescript-eslint/explicit-module-boundary-types": ["off"],
},
},
// in test files, allow null assertions and anys and eslint is sometimes weird about the react-scope thing
{
files: ["*test.ts?(x)"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"react/display-name": "off",
},
},
// allow requires in non-transpiled JS files and logical key ordering in config files
{
files: [
"babel-node.js",
"*babel.config.js",
"env.config.js",
"next.config.js",
"webpack.config.js",
"packages/mobile-web/package-builder/**",
],
rules: {},
},
// setupTests can have separated imports for logical grouping
{
files: ["setupTests.ts"],
rules: {
"import/newline-after-import": "off",
},
},
// turn OFF unused vars via eslint
{
files: ["*.ts", "*.tsx"],
rules: {
"@next/next/no-img-element": "off",
"@typescript-eslint/no-unused-vars": "off",
},
},
// THIS NEEDS TO GO LAST!
{
extends: ["biome"],
files: ["*.ts", "*.js", "*.tsx", "*.jsx"],
},
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
impliedStrict: true,
jsx: true,
},
ecmaVersion: 2019,
warnOnUnsupportedTypeScriptVersion: true,
},
plugins: ["@typescript-eslint", "react-compiler"],
rules: {
"react-compiler/react-compiler": "error",
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.name='useEffect']",
message:
'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".',
},
{
selector: "CallExpression[callee.name='createContext']",
message:
'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.',
},
{
selector: "CallExpression[callee.name='defineChain']",
message:
"Use useV5DashboardChain instead if you are using it inside a component",
},
{
selector: "CallExpression[callee.name='defineDashboardChain']",
message:
"Use useV5DashboardChain instead if you are using it inside a component",
},
{
selector: "CallExpression[callee.name='mapV4ChainToV5Chain']",
message:
"Use useV5DashboardChain instead if you are using it inside a component",
},
{
selector: "CallExpression[callee.name='resolveScheme']",
message:
"resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead",
},
],
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@chakra-ui/react",
// these are provided by tw-components, so we don't want to import them from chakra directly
importNames: [
"Card",
@@ -82,52 +128,77 @@ module.exports = {
],
message:
'Use the equivalent component from "tw-components" instead.',
name: "@chakra-ui/react",
},
{
name: "@chakra-ui/layout",
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/layout`.",
name: "@chakra-ui/layout",
},
{
name: "@chakra-ui/button",
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/button`.",
name: "@chakra-ui/button",
},
{
name: "@chakra-ui/menu",
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.",
name: "@chakra-ui/menu",
},
{
name: "next/navigation",
importNames: ["useRouter"],
message:
'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead',
name: "next/navigation",
},
{
name: "lucide-react",
importNames: ["Link", "Table", "Sidebar"],
message:
'This is likely a mistake. If you really want to import this - postfix the imported name with Icon. Example - "LinkIcon"',
name: "lucide-react",
},
{
name: "posthog-js",
message:
'Import "posthog-js" directly only within the analytics helpers ("src/@/analytics/*"). Use the exported helpers from "@/analytics/track" elsewhere.',
name: "posthog-js",
},
],
},
],
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "react-compiler"],
parserOptions: {
ecmaVersion: 2019,
ecmaFeatures: {
impliedStrict: true,
jsx: true,
},
warnOnUnsupportedTypeScriptVersion: true,
"no-restricted-syntax": [
"error",
{
message:
'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".',
selector: "CallExpression[callee.name='useEffect']",
},
{
message:
'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.',
selector: "CallExpression[callee.name='createContext']",
},
{
message:
"Use useV5DashboardChain instead if you are using it inside a component",
selector: "CallExpression[callee.name='defineChain']",
},
{
message:
"Use useV5DashboardChain instead if you are using it inside a component",
selector: "CallExpression[callee.name='defineDashboardChain']",
},
{
message:
"Use useV5DashboardChain instead if you are using it inside a component",
selector: "CallExpression[callee.name='mapV4ChainToV5Chain']",
},
{
message:
"resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead",
selector: "CallExpression[callee.name='resolveScheme']",
},
],
"react-compiler/react-compiler": "error",
},
settings: {
react: {
@@ -136,67 +207,4 @@ module.exports = {
version: "detect",
},
},
overrides: [
// disable restricted imports in tw-components
{
files: "src/tw-components/**/*",
rules: {
"no-restricted-imports": ["off"],
},
},
// allow direct PostHog imports inside analytics helpers
{
files: "src/@/analytics/**/*",
rules: {
"no-restricted-imports": ["off"],
},
},
// enable rule specifically for TypeScript files
{
files: ["*.ts", "*.tsx"],
rules: {
"@typescript-eslint/explicit-module-boundary-types": ["off"],
},
},
// in test files, allow null assertions and anys and eslint is sometimes weird about the react-scope thing
{
files: ["*test.ts?(x)"],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"react/display-name": "off",
},
},
// allow requires in non-transpiled JS files and logical key ordering in config files
{
files: [
"babel-node.js",
"*babel.config.js",
"env.config.js",
"next.config.js",
"webpack.config.js",
"packages/mobile-web/package-builder/**",
],
rules: {},
},
// setupTests can have separated imports for logical grouping
{
files: ["setupTests.ts"],
rules: {
"import/newline-after-import": "off",
},
},
// THIS NEEDS TO GO LAST!
{
files: ["*.ts", "*.js", "*.tsx", "*.jsx"],
extends: ["biome"],
},
],
env: {
browser: true,
node: true,
},
};
+2 -14
View File
@@ -1,16 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.2/schema.json",
"extends": ["../../biome.json"],
"overrides": [
{
"include": ["src/css/swagger-ui.css"],
"linter": {
"rules": {
"suspicious": {
"noImportantInKeyframe": "off"
}
}
}
}
]
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"extends": "//"
}
+12 -12
View File
@@ -2,29 +2,29 @@ import { defineConfig } from "checkly";
import { Frequency } from "checkly/constructs";
export default defineConfig({
projectName: "thirdweb.com",
logicalId: "thirdweb-www",
repoUrl: "https://github.com/thirdweb-dev/dashboard",
checks: {
activated: true,
muted: false,
runtimeId: "2023.09",
frequency: Frequency.EVERY_24H,
locations: ["us-east-1", "eu-west-1"],
tags: ["website"],
browserChecks: {
frequency: Frequency.EVERY_24H,
testMatch: "./tests/**/*.spec.ts",
},
checkMatch: "./**/*.check.ts",
frequency: Frequency.EVERY_24H,
ignoreDirectoriesMatch: [],
locations: ["us-east-1", "eu-west-1"],
muted: false,
playwrightConfig: {
use: {
baseURL: process.env.ENVIRONMENT_URL || "https://thirdweb.com",
},
},
browserChecks: {
frequency: Frequency.EVERY_24H,
testMatch: "./tests/**/*.spec.ts",
},
runtimeId: "2023.09",
tags: ["website"],
},
cli: {
runLocation: "eu-west-1",
},
logicalId: "thirdweb-www",
projectName: "thirdweb.com",
repoUrl: "https://github.com/thirdweb-dev/dashboard",
});
+12 -12
View File
@@ -1,17 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
},
"rsc": true,
"style": "default",
"tailwind": {
"baseColor": "neutral",
"config": "tailwind.config.js",
"css": "src/global.css",
"cssVariables": true,
"prefix": ""
},
"tsx": true
}
+4 -4
View File
@@ -1,18 +1,18 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"next": true,
"ignore": [
"src/@/components/ui/**",
"src/components/notices/AnnouncementBanner.tsx",
"src/components/cmd-k-search/index.tsx",
"src/lib/search.ts"
],
"project": ["src/**"],
"ignoreBinaries": ["only-allow", "biome"],
"ignoreBinaries": ["only-allow"],
"ignoreDependencies": [
"@thirdweb-dev/service-utils",
"@thirdweb-dev/vault-sdk",
"@types/color",
"fast-xml-parser"
]
],
"next": true,
"project": ["src/**"]
}
+34 -34
View File
@@ -44,12 +44,37 @@ async function getSingleChain(chainIdOrSlug) {
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.SITE_URL || "https://thirdweb.com",
additionalPaths: async (config) => {
const [framerUrls, allChains] = await Promise.all([
getFramerXML(),
fetchChainsFromApi(),
]);
return [
...framerUrls.map((url) => {
return {
changefreq: config.changefreq,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
loc: url.loc,
priority: config.priority,
};
}),
...allChains.map((chain) => {
return {
changefreq: config.changefreq,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
loc: `/${chain.slug}`,
priority: config.priority,
};
}),
...(await createSearchRecordSitemaps(config)),
];
},
exclude: ["/chain/validate"],
generateRobotsTxt: true,
robotsTxtOptions: {
policies: [
{
userAgent: "*",
// allow all if production
allow: process.env.VERCEL_ENV === "production" ? ["/"] : [],
// disallow all if not production
@@ -58,10 +83,11 @@ module.exports = {
? ["/"]
: // disallow `/team` and `/team/*` if production
["/team", "/team/*"],
userAgent: "*",
},
],
},
exclude: ["/chain/validate"],
siteUrl: process.env.SITE_URL || "https://thirdweb.com",
transform: async (config, _path) => {
let path = _path;
@@ -75,40 +101,14 @@ module.exports = {
path = path.replace("deployer.thirdweb.eth", "thirdweb.eth");
}
return {
alternateRefs: config.alternateRefs ?? [],
changefreq: config.changefreq,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
// => this will be exported as http(s)://<config.siteUrl>/<path>
loc: path,
changefreq: config.changefreq,
priority: config.priority,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
alternateRefs: config.alternateRefs ?? [],
};
},
additionalPaths: async (config) => {
const [framerUrls, allChains] = await Promise.all([
getFramerXML(),
fetchChainsFromApi(),
]);
return [
...framerUrls.map((url) => {
return {
loc: url.loc,
changefreq: config.changefreq,
priority: config.priority,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
};
}),
...allChains.map((chain) => {
return {
loc: `/${chain.slug}`,
changefreq: config.changefreq,
priority: config.priority,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
};
}),
...(await createSearchRecordSitemaps(config)),
];
},
};
/**
* @param {{ changefreq?: any; priority?: any; autoLastmod?: any; }} config
@@ -131,10 +131,10 @@ async function createSearchRecordSitemaps(config) {
parsedLines.map((parsedLine) => {
return getSingleChain(parsedLine.chain_id)
.then((parsedLineChain) => ({
loc: `/${parsedLineChain.slug}/${parsedLine.contract_address}`,
changefreq: config.changefreq,
priority: config.priority,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
loc: `/${parsedLineChain.slug}/${parsedLine.contract_address}`,
priority: config.priority,
}))
.catch(() => null);
}),
+54 -54
View File
@@ -46,91 +46,90 @@ function determineIpfsGateways() {
const remotePatterns: RemotePattern[] = [];
if (process.env.API_ROUTES_CLIENT_ID) {
remotePatterns.push({
protocol: "https",
hostname: `${process.env.API_ROUTES_CLIENT_ID}.ipfscdn.io`,
protocol: "https",
});
remotePatterns.push({
protocol: "https",
hostname: `${process.env.API_ROUTES_CLIENT_ID}.thirdwebstorage-staging.com`,
protocol: "https",
});
remotePatterns.push({
protocol: "https",
hostname: `${process.env.API_ROUTES_CLIENT_ID}.thirdwebstorage-dev.com`,
protocol: "https",
});
} else {
// this should only happen in development
remotePatterns.push({
protocol: "https",
hostname: "ipfs.io",
protocol: "https",
});
}
// also add the dashboard clientId ipfs gateway if it is set
if (process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID) {
remotePatterns.push({
protocol: "https",
hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.ipfscdn.io`,
protocol: "https",
});
remotePatterns.push({
protocol: "https",
hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.thirdwebstorage-staging.com`,
protocol: "https",
});
remotePatterns.push({
protocol: "https",
hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.thirdwebstorage-dev.com`,
protocol: "https",
});
}
return remotePatterns;
}
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",
// 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,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
org: "thirdweb-dev",
project: "dashboard",
// Suppresses source map uploading logs during build
silent: true,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: "/err",
// 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,
};
// add additional languages to the framer rewrite paths here (english is already included by default)
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
const baseNextConfig: NextConfig = {
compiler: {
emotion: true,
},
eslint: {
ignoreDuringBuilds: true,
},
productionBrowserSourceMaps: false,
experimental: {
webpackBuildWorker: true,
webpackMemoryOptimizations: true,
serverSourceMaps: false,
taint: true,
webpackBuildWorker: true,
webpackMemoryOptimizations: true,
},
serverExternalPackages: ["pino-pretty"],
async headers() {
return [
{
// Apply these headers to all routes in your application.
source: "/(.*)",
headers: [
...securityHeaders,
{
@@ -138,68 +137,69 @@ const baseNextConfig: NextConfig = {
value: "sec-ch-viewport-width",
},
],
// Apply these headers to all routes in your application.
source: "/(.*)",
},
];
},
images: {
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
dangerouslyAllowSVG: true,
remotePatterns: [
{
hostname: "**.thirdweb.com",
protocol: "https",
},
...determineIpfsGateways(),
],
},
productionBrowserSourceMaps: false,
reactStrictMode: true,
async redirects() {
return getRedirects();
},
async rewrites() {
return [
{
source: "/_ph/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
source: "/_ph/static/:path*",
},
{
source: "/_ph/:path*",
destination: "https://us.i.posthog.com/:path*",
source: "/_ph/:path*",
},
{
source: "/_ph/decide",
destination: "https://us.i.posthog.com/decide",
source: "/_ph/decide",
},
{
source: "/thirdweb.eth",
destination: "/deployer.thirdweb.eth",
source: "/thirdweb.eth",
},
{
source: "/thirdweb.eth/:path*",
destination: "/deployer.thirdweb.eth/:path*",
source: "/thirdweb.eth/:path*",
},
// re-write /home to / (this is so that logged in users will be able to go to /home and NOT be redirected to the logged in app)
{
source: "/home",
destination: "https://landing.thirdweb.com",
source: "/home",
},
// flatmap the framer paths for the default language and the additional languages
...FRAMER_PATHS.flatMap((path) => [
{
source: path,
destination: `https://landing.thirdweb.com${path}`,
source: path,
},
// this is for additional languages
...FRAMER_ADDITIONAL_LANGUAGES.map((lang) => ({
source: `/${lang}${path}`,
destination: `https://landing.thirdweb.com/${lang}${path}`,
source: `/${lang}${path}`,
})),
]),
];
},
images: {
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
remotePatterns: [
{
protocol: "https",
hostname: "**.thirdweb.com",
},
...determineIpfsGateways(),
],
},
compiler: {
emotion: true,
},
reactStrictMode: true,
serverExternalPackages: ["pino-pretty"],
};
function getConfig(): NextConfig {
+24 -23
View File
@@ -1,26 +1,4 @@
{
"name": "thirdweb-dashboard",
"version": "3.0.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --turbopack",
"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",
"fix": "biome check ./src --fix && eslint ./src --fix",
"typecheck": "tsc --noEmit",
"gen:theme-typings": "chakra-cli tokens src/theme/index.ts",
"postinstall": "pnpm run gen:theme-typings",
"postbuild": "next-sitemap",
"build:analyze": "ANALYZE=true pnpm run build",
"knip": "knip",
"playwright": "playwright test",
"update-checkly": "npx checkly deploy",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@chakra-ui/react": "^2.8.2",
"@chakra-ui/styled-system": "^2.9.2",
@@ -106,6 +84,7 @@
"zod": "3.25.62"
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "4.0.0",
"@next/bundle-analyzer": "15.3.3",
@@ -139,5 +118,27 @@
"storybook": "9.0.8",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
}
},
"name": "thirdweb-dashboard",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"gen:theme-typings": "chakra-cli tokens src/theme/index.ts",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"playwright": "playwright test",
"postbuild": "next-sitemap",
"postinstall": "pnpm run gen:theme-typings",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit",
"update-checkly": "npx checkly deploy"
},
"version": "3.0.0"
}
+18 -18
View File
@@ -10,25 +10,10 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Configure projects for major browsers */
projects: [
@@ -67,13 +52,28 @@ export default defineConfig({
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
testDir: "./tests",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Run your local dev server before starting the tests */
webServer: process.env.CI
? undefined
: {
command: "pnpm run start",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:3000",
},
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
});
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
tailwindcss: {},
},
};
+76 -76
View File
@@ -1,64 +1,64 @@
const legacyDashboardToTeamRedirects = [
{
source: "/dashboard",
destination: "/team",
permanent: false,
source: "/dashboard",
},
{
source: "/dashboard/contracts/:path*",
destination: "/team/~/~/contracts",
permanent: false,
source: "/dashboard/contracts/:path*",
},
{
source: "/dashboard/connect/ecosystem/:path*",
destination: "/team/~/~/ecosystem/:path*",
permanent: false,
source: "/dashboard/connect/ecosystem/:path*",
},
{
source: "/dashboard/engine/:path*",
destination: "/team/~/~/engine/:path*",
permanent: false,
source: "/dashboard/engine/:path*",
},
{
source: "/dashboard/settings/api-keys",
destination: "/team",
permanent: false,
source: "/dashboard/settings/api-keys",
},
{
source: "/dashboard/settings/devices",
destination: "/account/devices",
permanent: false,
source: "/dashboard/settings/devices",
},
{
source: "/dashboard/settings/billing",
destination: "/team/~/~/settings/billing",
permanent: false,
source: "/dashboard/settings/billing",
},
{
source: "/dashboard/settings/gas-credits",
destination: "/team/~/~/settings/credits",
permanent: false,
source: "/dashboard/settings/gas-credits",
},
{
source: "/dashboard/settings/usage",
destination: "/team/~/~/usage",
permanent: false,
source: "/dashboard/settings/usage",
},
{
source: "/dashboard/settings/storage",
destination: "/team/~/~/usage/storage",
permanent: false,
source: "/dashboard/settings/storage",
},
{
source: "/dashboard/settings/notifications",
destination: "/team/~/~/settings/notifications",
permanent: false,
source: "/dashboard/settings/notifications",
},
// rest of the /dashboard/* routes
{
source: "/dashboard/:path*",
destination: "/team",
permanent: false,
source: "/dashboard/:path*",
},
];
@@ -66,321 +66,321 @@ const legacyDashboardToTeamRedirects = [
async function redirects() {
return [
{
source: "/portal/:match*",
destination: "https://portal.thirdweb.com/:match*",
permanent: true,
source: "/portal/:match*",
},
{
source: "/solutions/appchain-api",
destination: "/solutions/chains",
permanent: true,
source: "/solutions/appchain-api",
},
{
source: "/contracts/release",
destination: "/contracts/publish",
permanent: false,
source: "/contracts/release",
},
{
source: "/contracts/release/:path*",
destination: "/contracts/publish/:path*",
permanent: false,
source: "/contracts/release/:path*",
},
{
source: "/release",
destination: "/publish",
permanent: false,
source: "/release",
},
{
source: "/release/:path*",
destination: "/publish/:path*",
permanent: false,
source: "/release/:path*",
},
{
source: "/authentication",
destination: "/auth",
permanent: false,
source: "/authentication",
},
{
source: "/extensions",
destination: "/build",
permanent: false,
source: "/extensions",
},
{
source: "/contractkit",
destination: "/build",
permanent: true,
source: "/contractkit",
},
// old (deprecated) routes
{
source:
"/:network/(edition|nft-collection|token|nft-drop|signature-drop|edition-drop|token-drop|vote)/:address",
destination: "/:network/:address",
permanent: false,
source:
"/:network/(edition|nft-collection|token|nft-drop|signature-drop|edition-drop|token-drop|vote)/:address",
},
// prebuilt contract deploys
{
source: "/contracts/new/:slug*",
destination: "/explore",
permanent: false,
source: "/contracts/new/:slug*",
},
// deployer to non-deployer url
// handled directly in SSR as well
{
source: "/deployer.thirdweb.eth",
destination: "/thirdweb.eth",
permanent: true,
source: "/deployer.thirdweb.eth",
},
{
source: "/deployer.thirdweb.eth/:path*",
destination: "/thirdweb.eth/:path*",
permanent: true,
source: "/deployer.thirdweb.eth/:path*",
},
{
source: "/chains",
destination: "/chainlist",
permanent: true,
source: "/chains",
},
// polygon zkevm beta to non-beta
{
source: "/polygon-zkevm-beta",
destination: "/polygon-zkevm",
permanent: false,
source: "/polygon-zkevm-beta",
},
// backwards compat: page moved to pages/settings/devices
{
source: "/template/nft-drop",
destination: "/template/erc721",
permanent: false,
source: "/template/nft-drop",
},
{
destination: "/team",
permanent: false,
source: "/create-api-key",
destination: "/team",
permanent: false,
},
{
destination: "/team",
permanent: false,
source: "/dashboard/settings",
destination: "/team",
permanent: false,
},
{
source: "/dashboard/connect/playground",
destination: "https://playground.thirdweb.com/connect/sign-in/button",
permanent: false,
source: "/dashboard/connect/playground",
},
{
source: "/dashboard/infrastructure/storage",
destination: "/dashboard/settings/storage",
permanent: false,
source: "/dashboard/infrastructure/storage",
},
{
source: "/dashboard/infrastructure/rpc-edge",
destination: "/chainlist",
permanent: false,
source: "/dashboard/infrastructure/rpc-edge",
},
{
source: "/solutions/commerce",
destination: "/solutions/loyalty",
permanent: false,
source: "/solutions/commerce",
},
{
source: "/hackathon/base-consumer-crypto",
destination: "/hackathon/consumer-crypto",
permanent: false,
source: "/hackathon/base-consumer-crypto",
},
{
source: "/bear-market-airdrop",
destination: "/",
permanent: false,
source: "/bear-market-airdrop",
},
{
source: "/drops/optimism",
destination: "/optimism",
permanent: false,
source: "/drops/optimism",
},
// Redirecting as ambassadors lives in community now
{
source: "/ambassadors",
destination: "/community/ambassadors",
permanent: false,
source: "/ambassadors",
},
{
source: "/embedded-wallets",
destination: "/in-app-wallets",
permanent: false,
source: "/embedded-wallets",
},
// temporarily redirect cli login to support page
{
source: "/cli/login",
destination:
"https://portal.thirdweb.com/knowledge-base/onchain-common-errors/thirdweb-cli/device-link-error",
permanent: false,
source: "/cli/login",
},
// temporary redirect gas -> explore page
{
source: "/gas",
destination: "/explore",
permanent: false,
source: "/gas",
},
{
destination: "/contracts/deployment-tool",
permanent: false,
source: "/deploy",
destination: "/contracts/deployment-tool",
permanent: false,
},
{
destination: "/contracts/deployment-tool",
permanent: false,
source: "/publish",
destination: "/contracts/deployment-tool",
permanent: false,
},
{
source: "/smart-contracts",
destination: "/contracts/explore",
permanent: false,
source: "/smart-contracts",
},
{
destination: "/sdk",
permanent: false,
source: "/ui-components",
destination: "/sdk",
permanent: false,
},
{
destination: "/sdk",
permanent: false,
source: "/interact",
destination: "/sdk",
permanent: false,
},
{
source: "/sponsored-transactions",
destination: "/account-abstraction",
permanent: false,
source: "/sponsored-transactions",
},
// redirect /solutions/chains to /solutions/ecosystem
{
source: "/solutions/chains",
destination: "/solutions/ecosystem",
permanent: false,
source: "/solutions/chains",
},
// redirect /rpc to portal
{
source: "/rpc-edge",
destination:
"https://portal.thirdweb.com/infrastructure/rpc-edge/overview",
permanent: false,
source: "/rpc-edge",
},
// redirect /sdk to portal
{
source: "/sdk",
destination: "https://portal.thirdweb.com/connect/blockchain-api",
permanent: false,
source: "/sdk",
},
// redirect `/events` to homepage
{
source: "/events",
destination: "/",
permanent: false,
source: "/events",
},
// redirect /community to /community/ambassadors
{
source: "/community",
destination: "/community/ambassadors",
permanent: false,
source: "/community",
},
// redirect `/tos` to `/terms`
{
source: "/tos",
destination: "/terms",
permanent: false,
source: "/tos",
},
// redirect `/privacy` to `/privacy-policy`
{
source: "/privacy",
destination: "/privacy-policy",
permanent: false,
source: "/privacy",
},
// redirect `/mission` to `/home`
{
source: "/mission",
destination: "/home",
permanent: false,
source: "/mission",
},
// redirect "/open-source" to "/bounties"
{
source: "/open-source",
destination: "/bounties",
permanent: false,
source: "/open-source",
},
// redirect /template/<slug> to /templates/<slug>
{
source: "/template/:slug",
destination: "/templates/:slug",
permanent: false,
source: "/template/:slug",
},
// redirect /connect/pay to /universal-bridge
{
source: "/connect/pay",
destination: "/universal-bridge",
permanent: false,
source: "/connect/pay",
},
// PREVIOUS CAMPAIGNS
{
source: "/unlimited-wallets",
destination: "/",
permanent: false,
source: "/unlimited-wallets",
},
// pay > universal-bridge redirect
{
source: "/team/:team_slug/:project_slug/connect/pay/:path*",
destination:
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
permanent: false,
source: "/team/:team_slug/:project_slug/connect/pay/:path*",
},
// all /learn/tutorials (and sub-routes) -> /learn/guides
{
source: "/learn/tutorials/:path*",
destination: "/learn/guides/:path*",
permanent: false,
source: "/learn/tutorials/:path*",
},
{
source: "/learn/tutorials",
destination: "/learn/guides",
permanent: false,
source: "/learn/tutorials",
},
// redirect to /grant/superchain to /superchain
{
source: "/grant/superchain",
destination: "/superchain",
permanent: false,
source: "/grant/superchain",
},
// connect -> build redirects
{
source: "/connect",
destination: "/wallets",
permanent: false,
source: "/connect",
},
{
source: "/connect/account-abstraction",
destination: "/account-abstraction",
permanent: false,
source: "/connect/account-abstraction",
},
{
source: "/connect/universal-bridge",
destination: "/universal-bridge",
permanent: false,
source: "/connect/universal-bridge",
},
{
source: "/connect/auth",
destination: "/auth",
permanent: false,
source: "/connect/auth",
},
{
source: "/connect/in-app-wallets",
destination: "/in-app-wallets",
permanent: false,
source: "/connect/in-app-wallets",
},
{
source: "/engine",
destination: "/transactions",
permanent: false,
source: "/engine",
},
...legacyDashboardToTeamRedirects,
+39 -39
View File
@@ -5,28 +5,32 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://[email protected]/6690186",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.1,
allowUrls: [/thirdweb-dev\.com/i, /thirdweb\.com/, /thirdweb-preview\.com/],
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
Sentry.replayIntegration({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
denyUrls: [
// Google Adsense
/pagead\/js/i,
// Facebook flakiness
/graph\.facebook\.com/i,
// Facebook blocked
/connect\.facebook\.net\/en_US\/all\.js/i,
// Woopra flakiness
/eatdifferent\.com\.woopra-ns\.com/i,
/static\.woopra\.com\/js\/woopra\.js/i,
// Chrome extensions
/extensions\//i,
/^chrome:\/\//i,
// Other plugins
// Cacaoweb
/127\.0\.0\.1:4001\/isrunning/i,
/webappstoolbarba\.texthelp\.com\//i,
/metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
// injected (extensions)
/inject/i,
],
dsn: "https://[email protected]/6690186",
ignoreErrors: [
// Random plugins/extensions
"top.GLOBALS",
@@ -72,26 +76,22 @@ Sentry.init({
// cannot do anything with these errors
"Non-Error promise rejection captured",
],
denyUrls: [
// Google Adsense
/pagead\/js/i,
// Facebook flakiness
/graph\.facebook\.com/i,
// Facebook blocked
/connect\.facebook\.net\/en_US\/all\.js/i,
// Woopra flakiness
/eatdifferent\.com\.woopra-ns\.com/i,
/static\.woopra\.com\/js\/woopra\.js/i,
// Chrome extensions
/extensions\//i,
/^chrome:\/\//i,
// Other plugins
// Cacaoweb
/127\.0\.0\.1:4001\/isrunning/i,
/webappstoolbarba\.texthelp\.com\//i,
/metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
// injected (extensions)
/inject/i,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
Sentry.replayIntegration({
blockAllMedia: true,
// Additional Replay configuration goes in here, for example:
maskAllText: true,
}),
],
allowUrls: [/thirdweb-dev\.com/i, /thirdweb\.com/, /thirdweb-preview\.com/],
replaysOnErrorSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.1,
});
+7 -7
View File
@@ -11,20 +11,20 @@ export async function acceptInvite(options: {
if (!token) {
return {
ok: false,
errorMessage: "You are not authorized to perform this action",
ok: false,
};
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/invites/${options.inviteId}/accept`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({}),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
@@ -42,8 +42,8 @@ export async function acceptInvite(options: {
} catch {}
return {
ok: false,
errorMessage,
ok: false,
};
}
+5 -5
View File
@@ -16,12 +16,12 @@ export async function reSubscribePlan(options: {
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({}),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "PUT",
},
);
+5 -5
View File
@@ -15,14 +15,14 @@ export async function confirmEmailWithOTP(otp: string) {
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account/confirmEmail`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
confirmationToken: otp,
}),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "PUT",
},
);
+16 -19
View File
@@ -3,62 +3,59 @@ import "server-only";
// biome-ignore lint/style/useNodejsImportProtocol: breaks storybook if it's `node:` prefixed
import { randomBytes } from "crypto";
import type { Team } from "@/api/team";
import { format } from "date-fns";
import type { Team } from "@/api/team";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
export async function createTeam(options?: {
name?: string;
slug?: string;
}) {
export async function createTeam(options?: { name?: string; slug?: string }) {
const token = await getAuthToken();
if (!token) {
return {
status: "error",
errorMessage: "You are not authorized to perform this action",
status: "error",
} as const;
}
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, {
method: "POST",
body: JSON.stringify({
billingEmail: null,
image: null,
name:
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
slug: options?.slug ?? randomBytes(20).toString("hex"),
}),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name:
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
slug: options?.slug ?? randomBytes(20).toString("hex"),
billingEmail: null,
image: null,
}),
method: "POST",
});
if (!res.ok) {
const reason = await res.text();
console.error("failed to create team", {
status: res.status,
reason,
status: res.status,
});
switch (res.status) {
case 400: {
return {
status: "error",
errorMessage: "Invalid team name or slug.",
status: "error",
} as const;
}
case 401: {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
status: "error",
} as const;
}
default: {
return {
status: "error",
errorMessage: "An unknown error occurred.",
status: "error",
} as const;
}
}
@@ -69,7 +66,7 @@ export async function createTeam(options?: {
};
return {
status: "success",
data: json.result,
status: "success",
} as const;
}
+9 -11
View File
@@ -3,63 +3,61 @@ import "server-only";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
export async function deleteTeam(options: {
teamId: string;
}) {
export async function deleteTeam(options: { teamId: string }) {
const token = await getAuthToken();
if (!token) {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
status: "error",
} as const;
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
method: "DELETE",
},
);
// handle errors
if (!res.ok) {
const reason = await res.text();
console.error("failed to delete team", {
status: res.status,
reason,
status: res.status,
});
switch (res.status) {
case 400: {
return {
status: "error",
errorMessage: "Invalid team ID.",
status: "error",
} as const;
}
case 401: {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
status: "error",
} as const;
}
case 403: {
return {
status: "error",
errorMessage: "You do not have permission to delete this team.",
status: "error",
} as const;
}
case 404: {
return {
status: "error",
errorMessage: "Team not found.",
status: "error",
} as const;
}
default: {
return {
status: "error",
errorMessage: "An unknown error occurred.",
status: "error",
} as const;
}
}
@@ -1,6 +1,6 @@
"use server";
import { defineDashboardChain } from "lib/defineDashboardChain";
import { ZERO_ADDRESS, isAddress, toTokens } from "thirdweb";
import { isAddress, toTokens, ZERO_ADDRESS } from "thirdweb";
import { getWalletBalance } from "thirdweb/wallets";
import { MORALIS_API_KEY } from "../constants/server-envs";
import { serverThirdwebClient } from "../constants/thirdweb-client.server";
@@ -43,12 +43,12 @@ export async function getTokenBalancesFromMoralis(params: {
});
return [
{
token_address: ZERO_ADDRESS,
symbol: balance.symbol,
name: "Native Token",
decimals: balance.decimals,
balance: balance.value.toString(),
decimals: balance.decimals,
display_balance: toTokens(balance.value, balance.decimals),
name: "Native Token",
symbol: balance.symbol,
token_address: ZERO_ADDRESS,
},
];
};
@@ -59,10 +59,10 @@ export async function getTokenBalancesFromMoralis(params: {
const tokenBalanceEndpoint = `https://deep-index.moralis.io/api/v2/${_address}/erc20?chain=${_chain}`;
const resp = await fetch(tokenBalanceEndpoint, {
method: "GET",
headers: {
"x-api-key": MORALIS_API_KEY,
},
method: "GET",
});
if (!resp.ok) {
@@ -83,7 +83,7 @@ export async function getTokenBalancesFromMoralis(params: {
]);
return {
error: undefined,
data: [...nativeBalance, ...tokenBalances],
error: undefined,
};
}
+16 -16
View File
@@ -53,7 +53,7 @@ export async function getWalletNFTs(params: {
const parsedResponse = await response.json();
const result = await transformAlchemyResponseToNFT(parsedResponse, owner);
return { result, error: undefined };
return { error: undefined, result };
} catch (err) {
console.error("Error fetching NFTs", err);
return { error: "error parsing response" };
@@ -64,10 +64,10 @@ export async function getWalletNFTs(params: {
const url = generateMoralisUrl({ chainId, owner });
const response = await fetch(url, {
method: "GET",
headers: {
"X-API-Key": MORALIS_API_KEY,
},
method: "GET",
next: {
revalidate: 10, // cache for 10 seconds
},
@@ -156,8 +156,8 @@ async function getWalletNFTsFromInsight(params: {
if (!response.ok) {
const errorMessage = await response.text();
return {
ok: false,
error: errorMessage,
ok: false,
};
}
@@ -172,32 +172,32 @@ async function getWalletNFTsFromInsight(params: {
const walletNFTs = nftsResponse.data.map((nft) => {
const walletNFT: WalletNFT = {
id: nft.token_id,
chainId: nft.contract.chain_id,
contractAddress: nft.contract.address,
id: nft.token_id,
metadata: {
uri: isDev
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.metadata_url,
name: nft.name,
description: nft.description,
image: isDev
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.image_url,
animation_url: isDev
? nft.extra_metadata.animation_original_url?.replace(
"ipfscdn.io/",
"thirdwebstorage-dev.com/",
)
: nft.extra_metadata.animation_original_url,
external_url: nft.external_url,
background_color: nft.background_color,
description: nft.description,
external_url: nft.external_url,
image: isDev
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.image_url,
name: nft.name,
uri: isDev
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.metadata_url,
},
owner: params.owner,
tokenURI: nft.metadata_url,
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
supply: nft.balance,
tokenAddress: nft.contract.address,
chainId: nft.contract.chain_id,
tokenURI: nft.metadata_url,
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
};
return walletNFT;
+8 -8
View File
@@ -47,35 +47,35 @@ async function proxy<T>(
}
const res = await fetch(url, {
method: params.method,
body: params.body,
headers: {
...params.headers,
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
},
body: params.body,
method: params.method,
});
if (!res.ok) {
try {
const errorMessage = await res.text();
return {
status: res.status,
ok: false,
error: errorMessage || res.statusText,
ok: false,
status: res.status,
};
} catch {
return {
status: res.status,
ok: false,
error: res.statusText,
ok: false,
status: res.status,
};
}
}
return {
status: res.status,
ok: true,
data: params.parseAsText ? await res.text() : await res.json(),
ok: true,
status: res.status,
};
}
@@ -20,8 +20,8 @@ export async function sendTeamInvites(options: {
if (!token) {
return {
ok: false,
errorMessage: "You are not authorized to perform this action",
ok: false,
};
}
@@ -43,15 +43,15 @@ async function sendInvite(
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamId}/invites`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
inviteEmail: invite.email,
inviteRole: invite.role,
}),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
@@ -59,8 +59,8 @@ async function sendInvite(
const errorMessage = await res.text();
return {
email: invite.email,
ok: false,
errorMessage,
ok: false,
};
}
@@ -14,12 +14,12 @@ export async function updateAccount(values: {
}
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "PUT",
});
if (!res.ok) {
@@ -3,9 +3,7 @@
import posthog from "posthog-js";
import { useEffect } from "react";
export function useIdentifyTeam(opts?: {
teamId: string;
}) {
export function useIdentifyTeam(opts?: { teamId: string }) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
// if no teamId, don't identify
+5 -9
View File
@@ -115,9 +115,7 @@ export function reportOnboardingPlanSelectionSkipped() {
* @jnsdls
*
*/
export function reportOnboardingMembersInvited(properties: {
count: number;
}) {
export function reportOnboardingMembersInvited(properties: { count: number }) {
posthog.capture("onboarding members invited", {
count: properties.count,
});
@@ -185,9 +183,7 @@ export function reportOnboardingCompleted() {
* @jnsdls
*
*/
export function reportFaucetUsed(properties: {
chainId: number;
}) {
export function reportFaucetUsed(properties: { chainId: number }) {
posthog.capture("faucet used", {
chainId: properties.chainId,
});
@@ -217,8 +213,8 @@ export function reportChainConfigurationAdded(properties: {
posthog.capture("chain configuration added", {
chainId: properties.chainId,
chainName: properties.chainName,
rpcURLs: properties.rpcURLs,
nativeCurrency: properties.nativeCurrency,
rpcURLs: properties.rpcURLs,
});
}
@@ -242,9 +238,9 @@ export function reportAssetBuySuccessful(properties: {
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy successful", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
assetType: properties.assetType,
});
}
@@ -263,9 +259,9 @@ export function reportAssetBuyFailed(properties: {
error: string;
}) {
posthog.capture("asset buy failed", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
assetType: properties.assetType,
error: properties.error,
});
}
+8 -8
View File
@@ -175,9 +175,9 @@ export async function getAggregateUserOpUsage(
},
{
date: (params.from || new Date()).toISOString(),
successful: 0,
failed: 0,
sponsoredUsd: 0,
successful: 0,
},
);
}
@@ -279,14 +279,14 @@ export async function isProjectActive(params: {
);
return {
bundler: false,
storage: false,
rpc: false,
nebula: false,
sdk: false,
insight: false,
pay: false,
inAppWallet: false,
ecosystemWallet: false,
inAppWallet: false,
insight: false,
nebula: false,
pay: false,
rpc: false,
sdk: false,
storage: false,
} as ActiveStatus;
}
+7 -7
View File
@@ -58,34 +58,34 @@ export async function getAuditLogs(teamSlug: string, cursor?: string) {
url.searchParams.set("take", "15");
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
// revalidate this query once per 10 seconds (does not need to be more granular than that)
revalidate: 10,
},
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (!response.ok) {
// if the status is 402, the most likely reason is that the team is on a free plan
if (response.status === 402) {
return {
status: "error",
reason: "higher_plan_required",
status: "error",
} as const;
}
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
reason: "unknown",
status: "error",
} as const;
}
const data = (await response.json()) as AuditLogApiResponse;
return {
status: "success",
data,
status: "success",
} as const;
}
+13 -13
View File
@@ -80,13 +80,13 @@ export async function createWebhook(
const response = await fetch(
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks`,
{
method: "POST",
body: JSON.stringify(payload),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(payload),
method: "POST",
},
);
@@ -115,11 +115,11 @@ export async function getWebhooks(
const response = await fetch(
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks`,
{
method: "GET",
headers: {
"x-client-id": clientId,
Authorization: `Bearer ${authToken}`,
"x-client-id": clientId,
},
method: "GET",
},
);
@@ -149,11 +149,11 @@ export async function deleteWebhook(
const response = await fetch(
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks/${encodeURIComponent(webhookId)}`,
{
method: "DELETE",
headers: {
"x-client-id": clientId,
Authorization: `Bearer ${authToken}`,
"x-client-id": clientId,
},
method: "DELETE",
},
);
@@ -183,29 +183,29 @@ export async function testWebhook(
const response = await fetch(
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks/test`,
{
method: "POST",
body: JSON.stringify(payload),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(payload),
method: "POST",
},
);
if (!response.ok) {
const errorText = await response.text();
return {
success: false,
error: `Failed to test webhook: ${errorText}`,
success: false,
};
}
return (await response.json()) as TestWebhookResponse;
} catch (error) {
return {
success: false,
error: `Network or parsing error: ${error instanceof Error ? error.message : "Unknown error"}`,
success: false,
};
}
}
@@ -215,10 +215,10 @@ export async function getSupportedWebhookChains(): Promise<SupportedWebhookChain
const response = await fetch(
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/service/chains`,
{
method: "GET",
headers: {
"x-service-api-key": process.env.INSIGHT_SERVICE_API_KEY || "",
},
method: "GET",
},
);
+14 -14
View File
@@ -41,17 +41,17 @@ export async function getUnreadNotifications(cursor?: string) {
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
reason: "unknown",
status: "error",
} as const;
}
const data = (await response.json()) as NotificationsApiResponse;
return {
status: "success",
data,
status: "success",
} as const;
}
@@ -77,17 +77,17 @@ export async function getArchivedNotifications(cursor?: string) {
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
reason: "unknown",
status: "error",
} as const;
}
const data = (await response.json()) as NotificationsApiResponse;
return {
status: "success",
data,
status: "success",
} as const;
}
@@ -109,9 +109,9 @@ export async function getUnreadNotificationsCount() {
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
reason: "unknown",
status: "error",
} as const;
}
const data = (await response.json()) as {
@@ -120,8 +120,8 @@ export async function getUnreadNotificationsCount() {
};
};
return {
status: "success",
data,
status: "success",
} as const;
}
@@ -135,20 +135,20 @@ export async function markNotificationAsRead(notificationId?: string) {
NEXT_PUBLIC_THIRDWEB_API_HOST,
);
const response = await fetch(url, {
method: "PUT",
// if notificationId is provided, mark it as read, otherwise mark all as read
body: JSON.stringify(notificationId ? { notificationId } : {}),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
// if notificationId is provided, mark it as read, otherwise mark all as read
body: JSON.stringify(notificationId ? { notificationId } : {}),
method: "PUT",
});
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
reason: "unknown",
status: "error",
} as const;
}
return {
+1 -1
View File
@@ -1,6 +1,6 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
export type Project = ProjectResponse;
+1 -1
View File
@@ -3,8 +3,8 @@ import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
const TeamAccountRole = {
OWNER: "OWNER",
MEMBER: "MEMBER",
OWNER: "OWNER",
} as const;
export type TeamAccountRole =
+2 -2
View File
@@ -1,8 +1,8 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import type { TeamResponse } from "@thirdweb-dev/service-utils";
import { cookies } from "next/headers";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import { getValidAccount } from "../../app/(app)/account/settings/getAccount";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { LAST_USED_TEAM_ID } from "../../constants/cookies";
@@ -14,19 +14,16 @@ type Webhook = {
version?: number; // TODO (UB) make this mandatory after migration
};
export async function getWebhooks(props: {
clientId: string;
teamId: string;
}) {
export async function getWebhooks(props: { clientId: string; teamId: string }) {
const authToken = await getAuthToken();
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
method: "GET",
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
Authorization: `Bearer ${authToken}`,
},
method: "GET",
});
if (!res.ok) {
@@ -49,19 +46,19 @@ export async function createWebhook(props: {
const authToken = await getAuthToken();
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
method: "POST",
body: JSON.stringify({
url: props.url,
label: props.label,
version: props.version,
secret: props.secret,
url: props.url,
version: props.version,
}),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
Authorization: `Bearer ${authToken}`,
},
method: "POST",
});
if (!res.ok) {
@@ -81,13 +78,13 @@ export async function deleteWebhook(props: {
const res = await fetch(
`${UB_BASE_URL}/v1/developer/webhooks/${props.webhookId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
Authorization: `Bearer ${authToken}`,
},
method: "DELETE",
},
);
@@ -106,19 +103,16 @@ export type Fee = {
updatedAt: string;
};
export async function getFees(props: {
clientId: string;
teamId: string;
}) {
export async function getFees(props: { clientId: string; teamId: string }) {
const authToken = await getAuthToken();
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-team-id": props.teamId,
"x-client-id": props.clientId,
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
},
method: "GET",
});
if (!res.ok) {
@@ -138,17 +132,17 @@ export async function updateFee(props: {
}) {
const authToken = await getAuthToken();
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-team-id": props.teamId,
"x-client-id": props.clientId,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
feeRecipient: props.feeRecipient,
feeBps: props.feeBps,
feeRecipient: props.feeRecipient,
}),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
},
method: "PUT",
});
if (!res.ok) {
@@ -225,13 +219,13 @@ export async function getPayments(props: {
}
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
Authorization: `Bearer ${authToken}`,
},
method: "GET",
});
if (!res.ok) {
@@ -18,15 +18,13 @@ type PaymentLink = {
purchaseData: Record<string, unknown> | undefined;
};
export async function getPaymentLink(props: {
paymentId: string;
}) {
export async function getPaymentLink(props: { paymentId: string }) {
const res = await fetch(`${UB_BASE_URL}/v1/links/${props.paymentId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
method: "GET",
});
if (!res.ok) {
@@ -36,12 +34,12 @@ export async function getPaymentLink(props: {
const { data } = await res.json();
return {
clientId: data.clientId,
title: data.title,
imageUrl: data.imageUrl,
receiver: data.receiver,
destinationToken: data.destinationToken,
amount: data.amount ? BigInt(data.amount) : undefined,
clientId: data.clientId,
destinationToken: data.destinationToken,
imageUrl: data.imageUrl,
purchaseData: data.purchaseData,
receiver: data.receiver,
title: data.title,
} as PaymentLink;
}
@@ -1,7 +1,7 @@
"use server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { UB_BASE_URL } from "./constants";
export type TokenMetadata = {
@@ -13,9 +13,7 @@ export type TokenMetadata = {
iconUri?: string;
};
export async function getUniversalBridgeTokens(props: {
chainId?: number;
}) {
export async function getUniversalBridgeTokens(props: { chainId?: number }) {
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
if (props.chainId) {
@@ -24,11 +22,11 @@ export async function getUniversalBridgeTokens(props: {
url.searchParams.append("limit", "1000");
const res = await fetch(url.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
} as Record<string, string>,
method: "GET",
});
if (!res.ok) {
@@ -49,16 +47,16 @@ export async function addUniversalBridgeTokenRoute(props: {
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
const res = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
"x-client-id": props.project.publishableKey,
} as Record<string, string>,
body: JSON.stringify({
chainId: props.chainId,
tokenAddress: props.tokenAddress,
}),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
} as Record<string, string>,
method: "POST",
});
if (!res.ok) {
@@ -34,33 +34,33 @@ export async function getBilledUsage(teamSlug: string) {
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
// revalidate this query once per minute (does not need to be more granular than that)
revalidate: 60,
},
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!response.ok) {
// if the status is 404, the most likely reason is that the team is on a free plan
if (response.status === 404) {
return {
status: "error",
reason: "free_plan",
status: "error",
} as const;
}
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
reason: "unknown",
status: "error",
} as const;
}
const data = (await response.json()) as UsageApiResponse;
return {
status: "success",
data,
status: "success",
} as const;
}
+3 -7
View File
@@ -23,11 +23,7 @@ type Last24HoursRPCUsageApiResponse = {
};
export const getLast24HoursRPCUsage = unstable_cache(
async (params: {
teamId: string;
projectId?: string;
authToken: string;
}) => {
async (params: { teamId: string; projectId?: string; authToken: string }) => {
const analyticsEndpoint = ANALYTICS_SERVICE_URL;
const url = new URL(`${analyticsEndpoint}/v2/rpc/24-hours`);
url.searchParams.set("teamId", params.teamId);
@@ -44,16 +40,16 @@ export const getLast24HoursRPCUsage = unstable_cache(
if (!res.ok) {
const error = await res.text();
return {
ok: false as const,
error: error,
ok: false as const,
};
}
const resData = await res.json();
return {
ok: true as const,
data: resData.data as Last24HoursRPCUsageApiResponse,
ok: true as const,
};
},
["rpc-usage-last-24-hours:v2"],
+2 -2
View File
@@ -56,12 +56,12 @@ export async function createDomainVerification(
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/verified-domain`,
{
method: "POST",
body: JSON.stringify({ domain }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
@@ -5,9 +5,7 @@ import { useTheme } from "next-themes";
import { useEffect } from "react";
import chakraTheme from "../../theme";
export function ChakraProviderSetup(props: {
children: React.ReactNode;
}) {
export function ChakraProviderSetup(props: { children: React.ReactNode }) {
return (
<ChakraProvider theme={chakraTheme}>
{props.children}
@@ -1,6 +1,6 @@
"use client";
import { ClientOnly } from "@/components/blocks/client-only";
import { Suspense } from "react";
import { ClientOnly } from "@/components/blocks/client-only";
import { useIsMobile } from "../hooks/use-mobile";
export function ResponsiveLayout(props: {
+6 -6
View File
@@ -38,12 +38,12 @@ export function CheckoutButton(props: {
}}
>
<Link
target="_blank"
rel="noopener noreferrer"
href={buildCheckoutUrl({
teamSlug: props.teamSlug,
sku: props.sku,
teamSlug: props.teamSlug,
})}
rel="noopener noreferrer"
target="_blank"
>
{props.children}
</Link>
@@ -59,8 +59,8 @@ function BillingWarning({ teamSlug }: { teamSlug: string }) {
<p className="text-sm">
You have outstanding invoices. Please{" "}
<Link
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
className="font-medium text-amber-700 underline transition-colors hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-300"
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
>
pay them
</Link>{" "}
@@ -78,17 +78,17 @@ export function BillingPortalButton(props: {
return (
<Button
{...props.buttonProps}
asChild
className={cn(props.buttonProps?.className, "gap-2")}
disabled={props.buttonProps?.disabled}
asChild
onClick={async (e) => {
props.buttonProps?.onClick?.(e);
}}
>
<a
href={buildBillingPortalUrl({ teamSlug: props.teamSlug })}
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
{props.children}
</a>
@@ -8,8 +8,8 @@ import { Button } from "../../ui/button";
import { GradientAvatar } from "./GradientAvatar";
const meta = {
title: "blocks/Avatars/GradientAvatar",
component: Story,
title: "blocks/Avatars/GradientAvatar",
} satisfies Meta<typeof Story>;
export default meta;
@@ -26,64 +26,64 @@ function Story() {
<BadgeContainer label="No Src, No id - Skeleton">
<GradientAvatar
id={undefined}
src={undefined}
className="size-20"
client={storybookThirdwebClient}
id={undefined}
src={undefined}
/>
</BadgeContainer>
<BadgeContainer label="No Src, id=foo - Skeleton">
<GradientAvatar
id={"foo"}
src={undefined}
className="size-20"
client={storybookThirdwebClient}
id={"foo"}
src={undefined}
/>
</BadgeContainer>
<BadgeContainer label="Invalid Src, id=foo - Gradient">
<GradientAvatar
id={"foo"}
src={""}
className="size-20"
client={storybookThirdwebClient}
id={"foo"}
src={""}
/>
</BadgeContainer>
<BadgeContainer label="Invalid Src, id=bar - Gradient">
<GradientAvatar
id={"bar"}
src={""}
className="size-20"
client={storybookThirdwebClient}
id={"bar"}
src={""}
/>
</BadgeContainer>
<BadgeContainer label="Empty/Invalid Src, No id - default fallback">
<GradientAvatar
src="invalid-src"
id={undefined}
className="size-20"
client={storybookThirdwebClient}
id={undefined}
src="invalid-src"
/>
</BadgeContainer>
<BadgeContainer label="Valid Src, https">
<GradientAvatar
src="https://picsum.photos/200/300"
id={undefined}
className="size-20"
client={storybookThirdwebClient}
id={undefined}
src="https://picsum.photos/200/300"
/>
</BadgeContainer>
<BadgeContainer label="Valid Src, ipfs">
<GradientAvatar
src="ipfs://QmZbeJYEs7kCJHyQxjxU2SJUtjSAr4m87wzJFJUyWomKdj/Smily.svg"
id={undefined}
className="size-20"
client={storybookThirdwebClient}
id={undefined}
src="ipfs://QmZbeJYEs7kCJHyQxjxU2SJUtjSAr4m87wzJFJUyWomKdj/Smily.svg"
/>
</BadgeContainer>
@@ -100,7 +100,7 @@ function ToggleTest() {
return (
<div className="relative flex flex-col gap-10 border p-6">
<Button
variant="outline"
className="absolute top-6 right-6 inline-flex"
onClick={() => {
if (data) {
setData(undefined);
@@ -111,7 +111,7 @@ function ToggleTest() {
});
}
}}
className="absolute top-6 right-6 inline-flex"
variant="outline"
>
Toggle Src
</Button>
@@ -120,19 +120,19 @@ function ToggleTest() {
<BadgeContainer label="Valid Src">
<GradientAvatar
src={data?.src}
id={data?.id}
className="size-20"
client={storybookThirdwebClient}
id={data?.id}
src={data?.src}
/>
</BadgeContainer>
<BadgeContainer label="invalid Src">
<GradientAvatar
className="size-20"
src={data ? "invalid-src" : undefined}
id={undefined}
client={storybookThirdwebClient}
id={undefined}
src={data ? "invalid-src" : undefined}
/>
</BadgeContainer>
</div>
@@ -1,5 +1,5 @@
import { Img } from "@/components/blocks/Img";
import type { ThirdwebClient } from "thirdweb";
import { Img } from "@/components/blocks/Img";
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
import { cn } from "../../../lib/utils";
import { GradientBlobbie } from "./GradientBlobbie";
@@ -19,9 +19,9 @@ export function GradientAvatar(props: {
return (
<Img
src={resolvedSrc}
className={cn("rounded-full", props.className)}
fallback={props.id ? <GradientBlobbie id={props.id} /> : undefined}
src={resolvedSrc}
/>
);
}
@@ -8,9 +8,9 @@ import { Button } from "../../ui/button";
import { ProjectAvatar } from "./ProjectAvatar";
const meta = {
title: "blocks/Avatars/ProjectAvatar",
component: Story,
parameters: {},
title: "blocks/Avatars/ProjectAvatar",
} satisfies Meta<typeof Story>;
export default meta;
@@ -27,17 +27,17 @@ function Story() {
<BadgeContainer label="No Src - Skeleton">
<ProjectAvatar
src={undefined}
className="size-6"
client={storybookThirdwebClient}
src={undefined}
/>
</BadgeContainer>
<BadgeContainer label="Invalid/Empty Src - BoxIcon Fallback">
<ProjectAvatar
src={""}
className="size-6"
client={storybookThirdwebClient}
src={""}
/>
</BadgeContainer>
@@ -54,7 +54,7 @@ function ToggleTest() {
return (
<div className="relative flex flex-col gap-10 border p-6">
<Button
variant="outline"
className="absolute top-6 right-6 inline-flex"
onClick={() => {
if (data) {
setData(undefined);
@@ -65,7 +65,7 @@ function ToggleTest() {
});
}
}}
className="absolute top-6 right-6 inline-flex"
variant="outline"
>
Toggle Src
</Button>
@@ -74,17 +74,17 @@ function ToggleTest() {
<BadgeContainer label="Valid Src">
<ProjectAvatar
src={data?.src}
className="size-6"
client={storybookThirdwebClient}
src={data?.src}
/>
</BadgeContainer>
<BadgeContainer label="invalid Src">
<ProjectAvatar
src={data ? "invalid-src" : undefined}
className="size-6"
client={storybookThirdwebClient}
src={data ? "invalid-src" : undefined}
/>
</BadgeContainer>
</div>
@@ -1,6 +1,6 @@
import { Img } from "@/components/blocks/Img";
import { BoxIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { Img } from "@/components/blocks/Img";
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
import { cn } from "../../../lib/utils";
@@ -11,19 +11,19 @@ export function ProjectAvatar(props: {
}) {
return (
<Img
src={
resolveSchemeWithErrorHandler({
uri: props.src,
client: props.client,
}) || ""
}
className={cn("rounded-full border border-border", props.className)}
alt={""}
className={cn("rounded-full border border-border", props.className)}
fallback={
<div className="flex items-center justify-center bg-card">
<BoxIcon className="size-[50%] text-muted-foreground" />
</div>
}
src={
resolveSchemeWithErrorHandler({
client: props.client,
uri: props.src,
}) || ""
}
/>
);
}
@@ -3,13 +3,13 @@ import { BadgeContainer } from "../../../stories/utils";
import { DangerSettingCard } from "./DangerSettingCard";
const meta = {
title: "blocks/Cards/DangerSettingCard",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/Cards/DangerSettingCard",
} satisfies Meta<typeof Story>;
export default meta;
@@ -24,29 +24,29 @@ function Story() {
<div className="container flex max-w-[1000px] flex-col gap-8 py-10">
<BadgeContainer label="Base">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isPending={false}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
title: "This is confirmation title",
}}
description="This is a description"
isPending={false}
title="This is a title"
/>
</BadgeContainer>
<BadgeContainer label="Loading">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isPending={true}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
title: "This is confirmation title",
}}
description="This is a description"
isPending={true}
title="This is a title"
/>
</BadgeContainer>
</div>
@@ -1,4 +1,4 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -10,7 +10,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState } from "react";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { cn } from "../../lib/utils";
import { DynamicHeight } from "../ui/DynamicHeight";
@@ -58,19 +58,19 @@ export function DangerSettingCard(props: {
)}
>
<Dialog
open={isConfirmationDialogOpen}
onOpenChange={(v) => {
setIsConfirmationDialogOpen(v);
if (!v) {
props.confirmationDialog.onClose?.();
}
}}
open={isConfirmationDialogOpen}
>
<DialogTrigger asChild>
<Button
variant="destructive"
className="gap-2 bg-red-600 font-semibold text-white hover:bg-red-600/80"
disabled={props.isDisabled || props.isPending}
variant="destructive"
>
{props.isPending && <Spinner className="size-3" />}
{props.buttonLabel}
@@ -99,10 +99,10 @@ export function DangerSettingCard(props: {
</DialogClose>
<Button
variant="destructive"
className="gap-2 bg-red-600 font-semibold text-white hover:bg-red-600/80"
onClick={props.buttonOnClick}
disabled={props.isPending}
onClick={props.buttonOnClick}
variant="destructive"
>
{props.isPending && <Spinner className="size-3" />}
{props.buttonLabel}
@@ -1,9 +1,9 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { DownloadIcon } from "lucide-react";
import Papa from "papaparse";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { cn } from "../../lib/utils";
export function ExportToCSVButton(props: {
@@ -34,12 +34,12 @@ export function ExportToCSVButton(props: {
return (
<Button
variant="outline"
disabled={props.disabled || exportMutation.isPending}
className={cn("flex items-center gap-2 border text-xs", props.className)}
disabled={props.disabled || exportMutation.isPending}
onClick={async () => {
exportMutation.mutate();
}}
variant="outline"
>
{exportMutation.isPending ? (
<>
@@ -58,8 +58,8 @@ export function ExportToCSVButton(props: {
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
return Papa.unparse({
fields: data.header,
data: data.rows,
fields: data.header,
});
}
@@ -1,7 +1,7 @@
import { Label } from "@/components/ui/label";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { AsteriskIcon, InfoIcon } from "lucide-react";
import type React from "react";
import { Label } from "@/components/ui/label";
import { ToolTipLabel } from "@/components/ui/tooltip";
export function FormFieldSetup(props: {
htmlFor?: string;
@@ -2,13 +2,13 @@ import type { Meta, StoryObj } from "@storybook/nextjs";
import { ImageIcon } from "lucide-react";
import { useState } from "react";
import { BadgeContainer } from "../../../stories/utils";
import { Spinner } from "../ui/Spinner/Spinner";
import { Button } from "../ui/button";
import { Spinner } from "../ui/Spinner/Spinner";
import { Img } from "./Img";
const meta = {
title: "blocks/Img",
component: Story,
title: "blocks/Img",
} satisfies Meta<typeof Story>;
export default meta;
@@ -51,24 +51,24 @@ function Story() {
<BadgeContainer label="Custom Skeleton">
<Img
src={undefined}
className="size-20"
skeleton={
<div className="flex items-center justify-center rounded-lg border">
<Spinner className="size-6" />
</div>
}
className="size-20"
src={undefined}
/>
</BadgeContainer>
<BadgeContainer label="Custom Fallback">
<Img
className="size-20"
fallback={
<div className="flex items-center justify-center rounded-lg border">
<ImageIcon className="size-10 text-muted-foreground" />
</div>
}
className="size-20"
src="invalid-src"
/>
</BadgeContainer>
@@ -82,7 +82,7 @@ function ToggleTest() {
return (
<div className="relative flex flex-col gap-10 border p-6">
<Button
variant="outline"
className="absolute top-6 right-6 inline-flex"
onClick={() => {
if (src) {
setSrc(undefined);
@@ -90,7 +90,7 @@ function ToggleTest() {
setSrc("https://picsum.photos/400");
}
}}
className="absolute top-6 right-6 inline-flex"
variant="outline"
>
Toggle Src
</Button>
+10 -10
View File
@@ -53,30 +53,30 @@ export function Img(props: imgElementProps) {
<img
{...restProps}
// avoid setting empty src string to prevent request to the entire page
src={restProps.src || undefined}
ref={imgRef}
onError={() => {
setStatus("fallback");
}}
style={{
opacity: status === "loaded" ? 1 : 0,
...restProps.style,
}}
alt={restProps.alt || ""}
className={cn(
"fade-in-0 object-cover transition-opacity duration-300",
className,
)}
decoding="async"
onError={() => {
setStatus("fallback");
}}
ref={imgRef}
src={restProps.src || undefined}
style={{
opacity: status === "loaded" ? 1 : 0,
...restProps.style,
}}
/>
{status !== "loaded" && (
<div
style={restProps.style}
className={cn(
"fade-in-0 absolute inset-0 overflow-hidden transition-opacity duration-300 [&>*]:h-full [&>*]:w-full",
className,
)}
style={restProps.style}
>
{status === "pending" && (skeleton || defaultSkeleton)}
{status === "fallback" && (fallback || defaultFallback)}
@@ -1,10 +1,10 @@
"use client";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { ChevronDownIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { cn } from "../../lib/utils";
import {
RenderSidebarLinks,
@@ -35,7 +35,7 @@ export function MobileSidebar(props: {
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog onOpenChange={setIsOpen} open={isOpen}>
<DialogTrigger asChild>{props.trigger || defaultTrigger}</DialogTrigger>
<DialogContent
className="no-scrollbar max-h-[80vh] overflow-auto rounded-t-xl rounded-b-none p-4"
@@ -7,13 +7,13 @@ import {
import { MultiNetworkSelector } from "./NetworkSelectors";
const meta = {
title: "blocks/Cards/MultiNetworkSelector",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/Cards/MultiNetworkSelector",
} satisfies Meta<typeof Story>;
export default meta;
@@ -35,17 +35,14 @@ function Story() {
);
}
function Variant(props: {
label: string;
selectedChainIds: number[];
}) {
function Variant(props: { label: string; selectedChainIds: number[] }) {
const [chainIds, setChainIds] = useState<number[]>(props.selectedChainIds);
return (
<BadgeContainer label={props.label}>
<MultiNetworkSelector
selectedChainIds={chainIds}
client={storybookThirdwebClient}
onChange={setChainIds}
selectedChainIds={chainIds}
/>
</BadgeContainer>
);
@@ -1,12 +1,12 @@
"use client";
import { MultiSelect } from "@/components/blocks/multi-select";
import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { Badge } from "@/components/ui/badge";
import { ChainIconClient } from "components/icons/ChainIcon";
import { useAllChainsData } from "hooks/chains/allChains";
import { useCallback, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { MultiSelect } from "@/components/blocks/multi-select";
import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { Badge } from "@/components/ui/badge";
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
@@ -98,15 +98,15 @@ export function MultiNetworkSelector(props: {
<span className="flex grow gap-2 truncate text-left">
<ChainIconClient
className="size-5"
client={props.client}
loading="lazy"
src={chain.icon?.url}
client={props.client}
/>
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge variant="outline" className="gap-2">
<Badge className="gap-2" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
@@ -119,24 +119,24 @@ export function MultiNetworkSelector(props: {
return (
<MultiSelect
searchPlaceholder="Search by Name or Chain Id"
selectedValues={props.selectedChainIds.map(String)}
popoverContentClassName={props.popoverContentClassName}
customTrigger={props.customTrigger}
options={options}
align={props.align}
side={props.side}
showSelectedValuesInModal={props.showSelectedValuesInModal}
className={props.className}
customTrigger={props.customTrigger}
disabled={allChains.length === 0}
onSelectedValuesChange={(chainIds) => {
props.onChange(chainIds.map(Number));
}}
options={options}
overrideSearchFn={searchFn}
placeholder={
allChains.length === 0 ? "Loading Chains..." : "Select Chains"
}
disabled={allChains.length === 0}
overrideSearchFn={searchFn}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
className={props.className}
searchPlaceholder="Search by Name or Chain Id"
selectedValues={props.selectedChainIds.map(String)}
showSelectedValuesInModal={props.showSelectedValuesInModal}
side={props.side}
/>
);
}
@@ -208,15 +208,15 @@ export function SingleNetworkSelector(props: {
<span className="flex grow gap-2 truncate text-left">
<ChainIconClient
className="size-5"
src={chain.icon?.url}
client={props.client}
loading="lazy"
src={chain.icon?.url}
/>
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge variant="outline" className="gap-2 max-sm:hidden">
<Badge className="gap-2 max-sm:hidden" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
@@ -231,26 +231,26 @@ export function SingleNetworkSelector(props: {
return (
<SelectWithSearch
searchPlaceholder="Search by Name or Chain ID"
value={String(props.chainId)}
showCheck={false}
options={options}
align={props.align}
className={props.className}
closeOnSelect={true}
disabled={isLoadingChains}
onValueChange={(chainId) => {
props.onChange(Number(chainId));
}}
closeOnSelect={true}
options={options}
overrideSearchFn={searchFn}
placeholder={
isLoadingChains
? "Loading Chains..."
: props.placeholder || "Select Chain"
}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
popoverContentClassName={props.popoverContentClassName}
disabled={isLoadingChains}
renderOption={renderOption}
searchPlaceholder="Search by Name or Chain ID"
showCheck={false}
side={props.side}
align={props.align}
value={String(props.chainId)}
/>
);
}
@@ -1,7 +1,7 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type React from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { cn } from "@/lib/utils";
export function RouteDiscoveryCard(
props: React.PropsWithChildren<{
@@ -66,12 +66,12 @@ export function RouteDiscoveryCard(
{props.saveButton && !props.noPermissionText && (
<Button
size="sm"
className={cn("gap-2", props.saveButton.className)}
onClick={props.saveButton.onClick}
disabled={props.saveButton.disabled || props.saveButton.isPending}
variant={props.saveButton.variant || "outline"}
onClick={props.saveButton.onClick}
size="sm"
type={props.saveButton.type}
variant={props.saveButton.variant || "outline"}
>
{props.saveButton.isPending && <Spinner className="size-3" />}
{props.saveButton.label ||
@@ -3,13 +3,13 @@ import { BadgeContainer } from "../../../stories/utils";
import { SettingsCard } from "./SettingsCard";
const meta = {
title: "Blocks/Cards/SettingsCard",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "Blocks/Cards/SettingsCard",
} satisfies Meta<typeof Story>;
export default meta;
@@ -25,8 +25,8 @@ function Story() {
<BadgeContainer label="No Header">
<SettingsCard
bottomText="This is bottom text"
header={undefined}
errorText={undefined}
header={undefined}
noPermissionText={undefined}
saveButton={undefined}
>
@@ -37,11 +37,11 @@ function Story() {
<BadgeContainer label="Header">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
errorText={undefined}
header={{
description: "This is some description for this card",
title: "Some Title",
}}
noPermissionText={undefined}
saveButton={undefined}
>
@@ -52,11 +52,11 @@ function Story() {
<BadgeContainer label="Error">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
errorText="This is error text"
header={{
description: "This is some description for this card",
title: "Some Title",
}}
noPermissionText={undefined}
saveButton={undefined}
>
@@ -67,17 +67,17 @@ function Story() {
<BadgeContainer label="No Permission">
<SettingsCard
bottomText="This is bottom text"
errorText={undefined}
header={{
title: "Some Title",
description: "This is some description for this card",
title: "Some Title",
}}
noPermissionText="You do not have permission to edit this"
saveButton={{
disabled: false,
isPending: false,
onClick: () => {},
}}
noPermissionText="You do not have permission to edit this"
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
@@ -86,17 +86,17 @@ function Story() {
<BadgeContainer label="Save">
<SettingsCard
bottomText="This is bottom text"
errorText={undefined}
header={{
title: "Some Title",
description: "This is some description for this card",
title: "Some Title",
}}
noPermissionText={undefined}
saveButton={{
disabled: false,
isPending: false,
onClick: () => {},
}}
noPermissionText={undefined}
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
@@ -105,17 +105,17 @@ function Story() {
<BadgeContainer label="Save Disabled">
<SettingsCard
bottomText="This is bottom text"
errorText={undefined}
header={{
title: "Some Title",
description: "This is some description for this card",
title: "Some Title",
}}
noPermissionText={undefined}
saveButton={{
disabled: true,
isPending: false,
onClick: () => {},
}}
noPermissionText={undefined}
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
@@ -124,17 +124,17 @@ function Story() {
<BadgeContainer label="Save Loading">
<SettingsCard
bottomText="This is bottom text"
errorText={undefined}
header={{
title: "Some Title",
description: "This is some description for this card",
title: "Some Title",
}}
noPermissionText={undefined}
saveButton={{
disabled: false,
isPending: true,
onClick: () => {},
}}
noPermissionText={undefined}
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
@@ -1,7 +1,7 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type React from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { cn } from "@/lib/utils";
export function SettingsCard(
props: React.PropsWithChildren<{
@@ -66,12 +66,12 @@ export function SettingsCard(
{props.saveButton && !props.noPermissionText && (
<Button
size="sm"
className={cn("gap-2", props.saveButton.className)}
onClick={props.saveButton.onClick}
disabled={props.saveButton.disabled || props.saveButton.isPending}
variant={props.saveButton.variant || "outline"}
onClick={props.saveButton.onClick}
size="sm"
type={props.saveButton.type}
variant={props.saveButton.variant || "outline"}
>
{props.saveButton.isPending && <Spinner className="size-3" />}
{props.saveButton.label ||
@@ -65,12 +65,12 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
const isExternal = link.href.startsWith("http");
return (
<NavLink
activeClassName="text-foreground bg-accent"
className="flex items-center gap-2 rounded-md px-3 py-2 text-muted-foreground text-sm hover:bg-accent"
exactMatch={link.exactMatch}
href={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-accent"
activeClassName="text-foreground bg-accent"
exactMatch={link.exactMatch}
>
{link.icon && <link.icon className="size-4" />}
{link.label}
@@ -2,13 +2,13 @@ import type { Meta, StoryObj } from "@storybook/nextjs";
import { SidebarLayout } from "./SidebarLayout";
const meta = {
title: "blocks/SidebarLayout",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/SidebarLayout",
} satisfies Meta<typeof Story>;
export default meta;
@@ -35,8 +35,8 @@ function Story() {
label: "eiusmod",
},
{
label: "incididunt",
href: "https://playground.thirdweb.com/",
label: "incididunt",
},
]}
>
@@ -15,9 +15,9 @@ import {
import { cn } from "../../lib/utils";
import { NavLink } from "../ui/NavLink";
import { Separator } from "../ui/separator";
import { AppFooter } from "./app-footer";
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
import { CustomSidebar, type SidebarLink } from "./Sidebar";
import { AppFooter } from "./app-footer";
export function SidebarLayout(props: {
sidebarLinks: SidebarLink[];
@@ -35,8 +35,8 @@ export function SidebarLayout(props: {
)}
>
<CustomSidebar
links={sidebarLinks}
className={props.desktopSidebarClassName}
links={sidebarLinks}
/>
<MobileSidebar
links={sidebarLinks}
@@ -65,13 +65,13 @@ export function FullWidthSidebarLayout(props: {
)}
>
{/* left - sidebar */}
<Sidebar collapsible="icon" side="left" className="pt-2">
<Sidebar className="pt-2" collapsible="icon" side="left">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<RenderSidebarGroup
sidebarLinks={contentSidebarLinks}
groupName={undefined}
sidebarLinks={contentSidebarLinks}
/>
</SidebarGroupContent>
</SidebarGroup>
@@ -80,8 +80,8 @@ export function FullWidthSidebarLayout(props: {
{footerSidebarLinks && (
<SidebarFooter className="pb-3">
<RenderSidebarGroup
sidebarLinks={footerSidebarLinks}
groupName={undefined}
sidebarLinks={footerSidebarLinks}
/>
</SidebarFooter>
)}
@@ -119,10 +119,10 @@ function RenderSidebarGroup(props: {
<SidebarMenuItem key={link.href}>
<SidebarMenuButton asChild>
<NavLink
href={link.href}
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
activeClassName="text-foreground bg-accent"
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
exactMatch={link.exactMatch}
href={link.href}
onClick={() => {
sidebar.setOpenMobile(false);
}}
@@ -136,14 +136,14 @@ function RenderSidebarGroup(props: {
}
if ("separator" in link) {
return <SidebarSeparator key={Math.random()} className="my-1" />;
return <SidebarSeparator className="my-1" key={Math.random()} />;
}
return (
<RenderSidebarGroup
sidebarLinks={link.links}
groupName={link.group}
key={link.group}
sidebarLinks={link.links}
/>
);
})}
@@ -157,7 +157,7 @@ function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
return (
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
<SidebarTrigger className="size-4" />
<Separator orientation="vertical" className="h-4" />
<Separator className="h-4" orientation="vertical" />
{activeLink && <span className="text-sm">{activeLink.label}</span>}
</div>
);
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { useCallback, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { MultiSelect } from "./multi-select";
interface SignatureOption {
@@ -85,38 +85,38 @@ export function SignatureSelector({
// Custom render for MultiSelect's search input
const customSearchInput = (
<input
ref={inputRef}
type="text"
autoComplete="off"
className={cn(
"w-full border-0 border-border border-b bg-transparent py-4 pr-2 pl-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0",
disabled && "cursor-not-allowed opacity-50",
)}
placeholder={placeholder}
value={searchValue}
disabled={disabled}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleInputKeyDown}
disabled={disabled}
autoComplete="off"
placeholder={placeholder}
ref={inputRef}
type="text"
value={searchValue}
/>
);
return (
<div className={className}>
<MultiSelect
options={allOptions}
selectedValues={value ? [value] : []}
onSelectedValuesChange={handleSelectedValuesChange}
placeholder={placeholder}
maxCount={1}
disabled={disabled}
searchPlaceholder={placeholder}
customSearchInput={customSearchInput}
customTrigger={null}
renderOption={(option) => <span>{option.label}</span>}
disabled={disabled}
maxCount={1}
onSelectedValuesChange={handleSelectedValuesChange}
options={allOptions}
overrideSearchFn={(option, searchTerm) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
option.value.toLowerCase().includes(searchTerm.toLowerCase())
}
customSearchInput={customSearchInput}
placeholder={placeholder}
renderOption={(option) => <span>{option.label}</span>}
searchPlaceholder={placeholder}
selectedValues={value ? [value] : []}
/>
{isCustomValue && (
<div className="mt-2 rounded border border-warning-200 bg-warning-50 px-2 py-1 text-warning-700 text-xs">
@@ -7,13 +7,13 @@ import {
import { SingleNetworkSelector } from "./NetworkSelectors";
const meta = {
title: "blocks/Cards/SingleNetworkSelector",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/Cards/SingleNetworkSelector",
} satisfies Meta<typeof Story>;
export default meta;
@@ -26,12 +26,12 @@ export const Variants: Story = {
function Story() {
return (
<div className="container flex max-w-6xl flex-col gap-8 py-10">
<Variant label="No Chain ID selected by default" chainId={undefined} />
<Variant label="Polygon selected by default" chainId={137} />
<Variant chainId={undefined} label="No Chain ID selected by default" />
<Variant chainId={137} label="Polygon selected by default" />
<Variant
label="Show certain chains only"
chainId={undefined}
chainIds={[1, 137, 10]}
label="Show certain chains only"
/>
</div>
);
@@ -46,10 +46,10 @@ function Variant(props: {
return (
<BadgeContainer label={props.label}>
<SingleNetworkSelector
client={storybookThirdwebClient}
chainId={chainId}
onChange={setChainId}
chainIds={props.chainIds}
client={storybookThirdwebClient}
onChange={setChainId}
/>
</BadgeContainer>
);
@@ -7,13 +7,13 @@ import {
import { TokenSelector } from "./TokenSelector";
const meta = {
title: "blocks/Cards/TokenSelector",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/Cards/TokenSelector",
} satisfies Meta<typeof Story>;
export default meta;
@@ -31,10 +31,7 @@ function Story() {
);
}
function Variant(props: {
label: string;
selectedChainId?: number;
}) {
function Variant(props: { label: string; selectedChainId?: number }) {
const [token, setToken] = useState<
| {
address: string;
@@ -47,8 +44,6 @@ function Variant(props: {
<BadgeContainer label={props.label}>
<TokenSelector
addNativeTokenIfMissing={false}
showCheck={false}
selectedToken={token}
chainId={props.selectedChainId}
client={storybookThirdwebClient}
onChange={(v) => {
@@ -57,6 +52,8 @@ function Variant(props: {
chainId: v.chainId,
});
}}
selectedToken={token}
showCheck={false}
/>
</BadgeContainer>
);
@@ -2,9 +2,9 @@ import { useAllChainsData } from "hooks/chains/allChains";
import { useTokensData } from "hooks/tokens/tokens";
import { useCallback, useMemo } from "react";
import {
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
getAddress,
} from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import { replaceIpfsUrl } from "../../../lib/sdk";
@@ -55,14 +55,14 @@ export function TokenSelector(props: {
if (!hasNativeToken && props.chainId) {
return [
{
address: checksummedNativeTokenAddress,
chainId: props.chainId,
decimals: 18,
name:
idToChain.get(props.chainId)?.nativeCurrency.name ??
"Native Token",
symbol:
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
decimals: 18,
chainId: props.chainId,
address: checksummedNativeTokenAddress,
} satisfies TokenMetadata,
...tokensQuery.data,
];
@@ -129,22 +129,22 @@ export function TokenSelector(props: {
<span className="flex grow gap-2 truncate text-left">
<Img
// render different image element if src changes to avoid showing old image while loading new one
key={resolvedSrc}
className={cn("size-5 rounded-full object-contain")}
src={resolvedSrc}
loading={"lazy"}
alt=""
className={cn("size-5 rounded-full object-contain")}
fallback={<img alt="" src={fallbackChainIcon} />}
key={resolvedSrc}
loading={"lazy"}
// eslint-disable-next-line @next/next/no-img-element
fallback={<img src={fallbackChainIcon} alt="" />}
skeleton={
<div className="animate-pulse rounded-full bg-border" />
}
src={resolvedSrc}
/>
{token.symbol}
</span>
{!props.disableAddress && (
<Badge variant="outline" className="gap-2 py-1 max-sm:hidden">
<Badge className="gap-2 py-1 max-sm:hidden" variant="outline">
<span className="text-muted-foreground">Address</span>
{shortenAddress(token.address, 4)}
</Badge>
@@ -172,9 +172,10 @@ export function TokenSelector(props: {
return (
<SelectWithSearch
searchPlaceholder="Search by name or symbol"
value={selectedValue}
options={options}
align={props.align}
className={props.className}
closeOnSelect={true}
disabled={tokensQuery.isPending || props.disabled}
onValueChange={(tokenAddress) => {
const token = addressChainToToken.get(tokenAddress);
if (!token) {
@@ -182,20 +183,19 @@ export function TokenSelector(props: {
}
props.onChange(token);
}}
closeOnSelect={true}
showCheck={props.showCheck}
options={options}
overrideSearchFn={searchFn}
placeholder={
tokensQuery.isPending
? "Loading Tokens"
: props.placeholder || "Select Token"
}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
popoverContentClassName={props.popoverContentClassName}
disabled={tokensQuery.isPending || props.disabled}
renderOption={renderOption}
searchPlaceholder="Search by name or symbol"
showCheck={props.showCheck}
side={props.side}
align={props.align}
value={selectedValue}
/>
);
}
@@ -8,48 +8,48 @@ function Story() {
<div className="container flex max-w-4xl flex-col gap-8 py-10">
<BadgeContainer label="Green with icon (default)">
<UpsellBannerCard
title="Unlock more with thirdweb"
description="Upgrade to increase limits and access advanced features."
accentColor="green"
cta={{
text: "View plans",
icon: <ArrowRightIcon className="size-4" />,
link: "#",
text: "View plans",
}}
description="Upgrade to increase limits and access advanced features."
icon={<RocketIcon className="size-5" />}
title="Unlock more with thirdweb"
trackingCategory="storybook"
trackingLabel="green"
icon={<RocketIcon className="size-5" />}
accentColor="green"
/>
</BadgeContainer>
<BadgeContainer label="Blue accent">
<UpsellBannerCard
title="Need more storage?"
description="Add additional space to your account."
accentColor="blue"
cta={{
text: "Upgrade storage",
icon: <ArrowRightIcon className="size-4" />,
link: "#",
text: "Upgrade storage",
}}
description="Add additional space to your account."
icon={<StarIcon className="size-5" />}
title="Need more storage?"
trackingCategory="storybook"
trackingLabel="blue"
icon={<StarIcon className="size-5" />}
accentColor="blue"
/>
</BadgeContainer>
<BadgeContainer label="Purple without leading icon">
<UpsellBannerCard
title="Join the beta"
description="Get early access to experimental features."
accentColor="purple"
cta={{
text: "Request access",
icon: <ArrowRightIcon className="size-4" />,
link: "#",
text: "Request access",
}}
description="Get early access to experimental features."
title="Join the beta"
trackingCategory="storybook"
trackingLabel="purple"
accentColor="purple"
/>
</BadgeContainer>
</div>
@@ -57,13 +57,13 @@ function Story() {
}
const meta = {
title: "blocks/Banners/UpsellBannerCard",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/Banners/UpsellBannerCard",
} satisfies Meta<typeof Story>;
export default meta;
@@ -1,37 +1,37 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const ACCENT = {
green: {
border: "border-green-600 dark:border-green-700",
bgFrom: "from-green-50 dark:from-green-900/20",
blur: "bg-green-600",
title: "text-green-900 dark:text-green-200",
desc: "text-green-800 dark:text-green-300",
iconBg: "bg-green-600 text-white",
btn: "bg-green-600 text-white hover:bg-green-700",
},
blue: {
border: "border-blue-600 dark:border-blue-700",
bgFrom: "from-blue-50 dark:from-blue-900/20",
blur: "bg-blue-600",
title: "text-blue-900 dark:text-blue-200",
border: "border-blue-600 dark:border-blue-700",
btn: "bg-blue-600 text-white hover:bg-blue-700",
desc: "text-blue-800 dark:text-blue-300",
iconBg: "bg-blue-600 text-white",
btn: "bg-blue-600 text-white hover:bg-blue-700",
title: "text-blue-900 dark:text-blue-200",
},
green: {
bgFrom: "from-green-50 dark:from-green-900/20",
blur: "bg-green-600",
border: "border-green-600 dark:border-green-700",
btn: "bg-green-600 text-white hover:bg-green-700",
desc: "text-green-800 dark:text-green-300",
iconBg: "bg-green-600 text-white",
title: "text-green-900 dark:text-green-200",
},
purple: {
border: "border-purple-600 dark:border-purple-700",
bgFrom: "from-purple-50 dark:from-purple-900/20",
blur: "bg-purple-600",
title: "text-purple-900 dark:text-purple-200",
border: "border-purple-600 dark:border-purple-700",
btn: "bg-purple-600 text-white hover:bg-purple-700",
desc: "text-purple-800 dark:text-purple-300",
iconBg: "bg-purple-600 text-white",
btn: "bg-purple-600 text-white hover:bg-purple-700",
title: "text-purple-900 dark:text-purple-200",
},
} as const;
@@ -97,18 +97,18 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) {
<Button
asChild
size="sm"
className={cn(
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
color.btn,
)}
size="sm"
>
<Link
href={props.cta.link}
target={props.cta.target}
rel={
props.cta.target === "_blank" ? "noopener noreferrer" : undefined
}
target={props.cta.target}
>
{props.cta.text}
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
@@ -1,5 +1,3 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ThirdwebMiniLogo } from "app/(app)/components/ThirdwebMiniLogo";
import { GithubIcon } from "components/icons/brand-icons/GithubIcon";
import { InstagramIcon } from "components/icons/brand-icons/InstagramIcon";
@@ -9,6 +7,8 @@ import { TiktokIcon } from "components/icons/brand-icons/TiktokIcon";
import { XIcon } from "components/icons/brand-icons/XIcon";
import { YoutubeIcon } from "components/icons/brand-icons/YoutubeIcon";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type AppFooterProps = {
className?: string;
@@ -69,34 +69,34 @@ export function AppFooter(props: AppFooterProps) {
</p>
</div>
<div className="flex flex-row gap-3">
<Button size="icon" variant="ghost" asChild className="size-9">
<Button asChild className="size-9" size="icon" variant="ghost">
<Link
href="https://github.com/thirdweb-dev"
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
<GithubIcon className="size-5 text-muted-foreground" />
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="size-9">
<Button asChild className="size-9" size="icon" variant="ghost">
<Link
href="https://www.tiktok.com/@thirdweb"
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
<TiktokIcon className="size-5 text-muted-foreground" />
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="size-9">
<Button asChild className="size-9" size="icon" variant="ghost">
<Link
href="https://www.instagram.com/thirdweb/"
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
<InstagramIcon className="size-5 text-muted-foreground" />
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="size-9">
<Button asChild className="size-9" size="icon" variant="ghost">
<Link
href="hhttps://www.linkedin.com/company/third-web/"
target="_blank"
@@ -104,29 +104,29 @@ export function AppFooter(props: AppFooterProps) {
<LinkedInIcon className="size-5 text-muted-foreground" />
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="size-9">
<Button asChild className="size-9" size="icon" variant="ghost">
<Link
href="https://www.youtube.com/@thirdweb_"
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
<YoutubeIcon className="size-5 text-muted-foreground" />
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="size-9">
<Button asChild className="size-9" size="icon" variant="ghost">
<Link
href="https://www.reddit.com/r/thirdweb/"
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
<RedditIcon className="size-5 text-muted-foreground" />
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="size-9">
<Button asChild className="size-9" size="icon" variant="ghost">
<Link
href="https://x.com/thirdweb"
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
<XIcon className="size-5 text-muted-foreground" />
</Link>
@@ -151,9 +151,9 @@ function FooterLink(props: {
}) {
return (
<Link
className="px-0 py-[6px] text-muted-foreground text-sm hover:underline"
href={props.href}
prefetch={false}
className="px-0 py-[6px] text-muted-foreground text-sm hover:underline"
target="_blank"
>
{props.label}
@@ -1,5 +1,12 @@
"use client";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { format } from "date-fns";
import { useMemo } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
Card,
CardContent,
@@ -16,13 +23,6 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart";
import { cn } from "@/lib/utils";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { format } from "date-fns";
import { useMemo } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
header?: {
@@ -77,7 +77,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
<CardContent
className={cn(!props.header && "pt-6", props.cardContentClassName)}
>
<ChartContainer config={props.config} className={props.chartClassName}>
<ChartContainer className={props.chartClassName} config={props.config}>
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
@@ -87,21 +87,20 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
) : (
<AreaChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
{props.yAxis && <YAxis tickLine={false} axisLine={false} />}
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tickMargin={20}
dataKey="time"
tickFormatter={(value) =>
format(
new Date(value),
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
)
}
tickLine={false}
tickMargin={20}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
hideLabel={
@@ -111,15 +110,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
valueFormatter={props.toolTipValueFormatter}
/>
}
cursor={true}
/>
<defs>
{configKeys.map((key) => (
<linearGradient
key={key}
id={`fill_${key}`}
key={key}
x1="0"
y1="0"
x2="0"
y1="0"
y2="1"
>
<stop
@@ -138,23 +138,23 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
{configKeys.map((key) =>
key === "maxLine" ? (
<Area
key={key}
type="monotone"
dataKey="maxLine"
stroke="#ef4444"
strokeWidth={2}
strokeDasharray="5 5"
fill="none"
key={key}
stroke="#ef4444"
strokeDasharray="5 5"
strokeWidth={2}
type="monotone"
/>
) : (
<Area
key={key}
dataKey={key}
type="natural"
fill={`url(#fill_${key})`}
fillOpacity={0.4}
stroke={`var(--color-${key})`}
key={key}
stackId={props.variant !== "stacked" ? undefined : "a"}
stroke={`var(--color-${key})`}
type="natural"
/>
),
)}
@@ -1,7 +1,12 @@
"use client";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { format } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import {
Card,
CardContent,
@@ -18,12 +23,6 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart";
import { cn } from "@/lib/utils";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { format } from "date-fns";
import { useMemo } from "react";
type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
// metadata
@@ -71,7 +70,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
{props.customHeader && props.customHeader}
<CardContent className={cn(!props.header && "pt-6")}>
<ChartContainer config={props.config} className={props.chartClassName}>
<ChartContainer className={props.chartClassName} config={props.config}>
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
@@ -82,11 +81,11 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
<BarChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tickMargin={10}
dataKey="time"
tickFormatter={(value) => format(new Date(value), "MMM d")}
tickLine={false}
tickMargin={10}
/>
<ChartTooltip
content={
@@ -106,15 +105,15 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
)}
{configKeys.map((key) => (
<Bar
key={key}
className="stroke-background"
dataKey={key}
// if stacked then they should all be the same stackId
// if grouped then they should all be unique stackId (so the key works great)
stackId={variant === "stacked" ? "a" : key}
fill={props.config[key]?.color}
key={key}
radius={[4, 4, 4, 4]}
stackId={variant === "stacked" ? "a" : key}
strokeWidth={1}
className="stroke-background"
/>
))}
</BarChart>
@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { type ReactNode, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface ClientOnlyProps {
/**
@@ -1,7 +1,7 @@
"use client";
import { CodeClient } from "@/components/ui/code/code.client";
import type React from "react";
import { type Dispatch, type SetStateAction, useMemo } from "react";
import { CodeClient } from "@/components/ui/code/code.client";
import { TabButtons } from "../ui/tabs";
export type CodeEnvironment =
@@ -91,38 +91,36 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
>
{!hideTabs && (
<TabButtons
tabs={environments.map((env) => ({
label: env.title,
onClick: () => setEnvironment(env.environment),
isActive: activeEnvironment === env.environment,
name: env.title,
}))}
tabClassName="text-sm gap-2 !text-sm"
tabIconClassName="size-4"
tabContainerClassName="px-3 pt-1.5 gap-0.5"
hideBottomLine={!!onlyTabs}
tabClassName="text-sm gap-2 !text-sm"
tabContainerClassName="px-3 pt-1.5 gap-0.5"
tabIconClassName="size-4"
tabs={environments.map((env) => ({
isActive: activeEnvironment === env.environment,
label: env.title,
name: env.title,
onClick: () => setEnvironment(env.environment),
}))}
/>
)}
{onlyTabs ? null : (
<>
<CodeClient
code={code}
className="rounded-none border-none"
lang={
isInstallCommand
? activeEnvironment === "react-native"
? "tsx"
: "bash"
: activeEnvironment === "react" ||
activeEnvironment === "react-native"
? "tsx"
: activeEnvironment === "unity"
? "cpp"
: activeEnvironment
}
/>
</>
<CodeClient
className="rounded-none border-none"
code={code}
lang={
isInstallCommand
? activeEnvironment === "react-native"
? "tsx"
: "bash"
: activeEnvironment === "react" ||
activeEnvironment === "react-native"
? "tsx"
: activeEnvironment === "unity"
? "cpp"
: activeEnvironment
}
/>
)}
</div>
);
@@ -1,3 +1,6 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { BadgeContainer } from "stories/utils";
import {
Select,
SelectContent,
@@ -6,19 +9,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { BadgeContainer } from "stories/utils";
import { type CodeEnvironment, CodeSegment } from "./code-segment.client";
const meta = {
title: "blocks/CodeSegment",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/CodeSegment",
} satisfies Meta<typeof Story>;
export default meta;
@@ -37,10 +37,10 @@ function Story() {
return (
<div className="container flex max-w-[700px] flex-col gap-10 py-10">
<Select
value={mode}
onValueChange={(v) => {
setMode(v as Mode);
}}
value={mode}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
@@ -58,12 +58,12 @@ function Story() {
</SelectContent>
</Select>
<Variant envsToShow={["javascript"]} storyLabel="1 env" mode={mode} />
<Variant envsToShow={["javascript"]} mode={mode} storyLabel="1 env" />
<Variant
envsToShow={["javascript", "typescript"]}
storyLabel="2 envs"
mode={mode}
storyLabel="2 envs"
/>
<Variant
@@ -74,8 +74,8 @@ function Story() {
"react-native",
"unity",
]}
storyLabel="5 envs"
mode={mode}
storyLabel="5 envs"
/>
</div>
);
@@ -92,23 +92,23 @@ function Variant(props: {
return (
<BadgeContainer label={props.storyLabel}>
<CodeSegment
environment={env}
hideTabs={props.mode === "no-tabs"}
onlyTabs={props.mode === "only-tabs"}
setEnvironment={setEnv}
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,
unity: props.envsToShow.includes("unity") ? unityCode : undefined,
}}
environment={env}
setEnvironment={setEnv}
hideTabs={props.mode === "no-tabs"}
onlyTabs={props.mode === "only-tabs"}
/>
</BadgeContainer>
);
@@ -20,9 +20,9 @@ export function DismissibleAlert(props: {
return (
<div className="relative rounded-lg border border-border bg-card p-4">
<Button
onClick={() => setIsVisible(false)}
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
aria-label="Close alert"
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
onClick={() => setIsVisible(false)}
variant="ghost"
>
<XIcon className="size-5" />
@@ -38,11 +38,11 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
{props.segments.map((segment) => {
return (
<div
key={segment.label}
className="flex h-full items-center justify-center transition-all duration-200"
key={segment.label}
style={{
width: `${segment.percent}%`,
backgroundColor: segment.color,
width: `${segment.percent}%`,
}}
/>
);
@@ -53,7 +53,7 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
<div className="mt-3 flex flex-col gap-1 lg:flex-row lg:gap-6">
{props.segments.map((segment) => {
return (
<div key={segment.label} className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5" key={segment.label}>
<div
className="size-3 rounded-full"
style={{
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/nextjs";
import { DropZone } from "./drop-zone";
const meta = {
title: "blocks/DropZone",
component: DropZone,
decorators: [
(Story) => (
@@ -11,6 +10,7 @@ const meta = {
</div>
),
],
title: "blocks/DropZone",
} satisfies Meta<typeof DropZone>;
export default meta;
@@ -18,36 +18,36 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
accept: undefined,
description: "This is a description for drop zone",
isError: false,
onDrop: () => {},
title: "This is a title",
description: "This is a description for drop zone",
accept: undefined,
resetButton: undefined,
title: "This is a title",
},
};
export const ErrorState: Story = {
args: {
accept: undefined,
description: "This is a description",
isError: true,
onDrop: () => {},
title: "this is title",
description: "This is a description",
accept: undefined,
resetButton: undefined,
title: "this is title",
},
};
export const ErrorStateWithResetButton: Story = {
args: {
accept: undefined,
description: "This is a description",
isError: true,
onDrop: () => {},
title: "this is title",
description: "This is a description",
accept: undefined,
resetButton: {
label: "Remove Files",
onClick: () => {},
},
title: "this is title",
},
};
@@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { UploadIcon, XIcon } from "lucide-react";
import { useDropzone } from "react-dropzone";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function DropZone(props: {
isError: boolean;
@@ -64,11 +64,11 @@ export function DropZone(props: {
{resetButton && (
<Button
className="relative z-50 mt-4"
size="sm"
onClick={(e) => {
e.stopPropagation();
resetButton.onClick();
}}
size="sm"
>
{resetButton.label}
</Button>
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { useMemo } from "react";
export function UnexpectedValueErrorMessage(props: {
value: unknown;
@@ -30,11 +30,11 @@ export function UnexpectedValueErrorMessage(props: {
</ScrollShadow>
<CopyTextButton
copyIconPosition="left"
textToShow="Copy value"
textToCopy={stringifiedValue}
tooltip="Copy value"
className="-translate-x-2 mt-1 text-muted-foreground"
copyIconPosition="left"
textToCopy={stringifiedValue}
textToShow="Copy value"
tooltip="Copy value"
variant="ghost"
/>
</div>
@@ -4,13 +4,13 @@ import { BadgeContainer } from "../../../stories/utils";
import { MultiSelect } from "./multi-select";
const meta = {
title: "blocks/MultiSelect",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/MultiSelect",
} satisfies Meta<typeof Story>;
export default meta;
@@ -22,26 +22,26 @@ export const Variants: Story = {
function createList(len: number) {
return Array.from({ length: len }, (_, i) => ({
value: `${i}`,
label: `Item ${i}`,
value: `${i}`,
}));
}
function Story() {
return (
<div className="container flex max-w-6xl flex-col gap-6 py-10">
<VariantTest defaultValues={[]} storyLabel="5 items" listLen={5} />
<VariantTest defaultValues={[]} storyLabel="5000 items" listLen={5000} />
<VariantTest defaultValues={[]} listLen={5} storyLabel="5 items" />
<VariantTest defaultValues={[]} listLen={5000} storyLabel="5000 items" />
<VariantTest
defaultValues={["1", "3", "5"]}
storyLabel="20 items, 3 selected by default"
listLen={20}
storyLabel="20 items, 3 selected by default"
/>
<VariantTest
defaultValues={["1", "2", "3", "4", "5", "6", "7"]}
storyLabel="20 items, 7 selected, show max 3"
listLen={20}
maxCount={3}
storyLabel="20 items, 7 selected, show max 3"
/>
</div>
);
@@ -59,13 +59,13 @@ function VariantTest(props: {
return (
<BadgeContainer label={props.storyLabel}>
<MultiSelect
selectedValues={values}
options={list}
maxCount={props.maxCount}
onSelectedValuesChange={(values) => {
setValues(values);
}}
options={list}
placeholder="Select items"
maxCount={props.maxCount}
selectedValues={values}
/>
</BadgeContainer>
);
@@ -1,6 +1,8 @@
/* eslint-disable no-restricted-syntax */
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
"use client";
import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
import { forwardRef, useCallback, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
@@ -9,18 +11,9 @@ import {
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useShowMore } from "../../lib/useShowMore";
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
import { Input } from "../ui/input";
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -150,58 +143,48 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
// scroll to top when options change
const popoverElRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}, [searchValue]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
<PopoverTrigger asChild>
{props.customTrigger || (
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
className,
)}
onClick={handleTogglePopover}
>
{selectedValues.length > 0 ? (
<div className="flex w-full items-center justify-between">
<SelectedChainsBadges
selectedValues={selectedValues}
options={options}
clearExtraOptions={clearExtraOptions}
maxCount={maxCount}
onClose={handleClear}
options={options}
selectedValues={selectedValues}
toggleOption={toggleOption}
clearExtraOptions={clearExtraOptions}
/>
<div className="flex items-center justify-between gap-2">
{/* Clear All */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
{/* biome-ignore lint/a11y/useFocusableInteractive: <explanation> */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: FIXME */}
{/* biome-ignore lint/a11y/useFocusableInteractive: FIXME */}
<div
role="button"
className="rounded p-1 hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
className="rounded p-1 hover:bg-accent"
role="button"
>
<XIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
<Separator
orientation="vertical"
className="flex h-full min-h-6"
orientation="vertical"
/>
<ChevronDownIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
@@ -218,20 +201,20 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
)}
</PopoverTrigger>
<PopoverContent
align={props.align}
className={cn(
"flex max-h-[500px] flex-col p-0",
popoverContentClassName,
)}
align={props.align}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
ref={popoverElRef}
side={props.side}
sideOffset={10}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
style={{
width: "var(--radix-popover-trigger-width)",
height:
"calc(var(--radix-popover-content-available-height) - 40px)",
width: "var(--radix-popover-trigger-width)",
}}
ref={popoverElRef}
>
{/* Search */}
<div className="relative">
@@ -239,13 +222,22 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
customSearchInput
) : (
<Input
placeholder={searchPlaceholder || "Search"}
value={searchValue}
// do not focus on the input when the popover opens to avoid opening the keyboard
tabIndex={-1}
onChange={(e) => setSearchValue(e.target.value)}
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => {
setSearchValue(e.target.value);
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}}
// do not focus on the input when the popover opens to avoid opening the keyboard
onKeyDown={handleInputKeyDown}
placeholder={searchPlaceholder || "Search"}
tabIndex={-1}
value={searchValue}
/>
)}
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
@@ -259,8 +251,8 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
{optionsToShow.length > 0 && (
<ScrollShadow
scrollableClassName="p-1 h-full"
className="flex-1 rounded"
scrollableClassName="p-1 h-full"
>
{/* List */}
<div>
@@ -268,15 +260,15 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
const isSelected = selectedValues.includes(option.value);
return (
<Button
key={option.value}
role="option"
aria-selected={isSelected}
onClick={() => toggleOption(option.value)}
variant="ghost"
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
key={option.value}
onClick={() => toggleOption(option.value)}
ref={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
role="option"
variant="ghost"
>
<div
className={cn(
@@ -302,12 +294,12 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
{showSelectedValuesInModal && selectedValues.length > 0 && (
<div className="border-t px-3 py-3">
<SelectedChainsBadges
selectedValues={selectedValues}
options={options}
clearExtraOptions={clearExtraOptions}
maxCount={maxCount}
onClose={handleClear}
options={options}
selectedValues={selectedValues}
toggleOption={toggleOption}
clearExtraOptions={clearExtraOptions}
/>
</div>
)}
@@ -317,10 +309,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
},
);
function ClosableBadge(props: {
label: string;
onClose: () => void;
}) {
function ClosableBadge(props: { label: string; onClose: () => void }) {
return (
<span className="flex items-center gap-3 rounded-xl border border-border bg-muted px-2 py-1 text-foreground text-xs">
{props.label}
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/nextjs";
import { MultiStepStatus } from "./multi-step-status";
const meta = {
title: "Blocks/MultiStepStatus",
component: MultiStepStatus,
decorators: [
(Story) => (
@@ -11,6 +10,7 @@ const meta = {
</div>
),
],
title: "Blocks/MultiStepStatus",
} satisfies Meta<typeof MultiStepStatus>;
export default meta;
@@ -21,29 +21,29 @@ export const AllStates: Story = {
onRetry: () => {},
steps: [
{
status: { type: "completed" },
label: "Connect Wallet",
id: "connect-wallet",
label: "Connect Wallet",
status: { type: "completed" },
},
{
status: { type: "pending" },
label: "Sign Message",
id: "sign-message",
label: "Sign Message",
status: { type: "pending" },
},
{
status: { type: "error", message: "This is an error message" },
label: "Approve Transaction",
id: "approve-transaction",
label: "Approve Transaction",
status: { message: "This is an error message", type: "error" },
},
{
status: { type: "idle" },
label: "Confirm Transaction",
id: "confirm-transaction",
label: "Confirm Transaction",
status: { type: "idle" },
},
{
status: { type: "idle" },
label: "Finalize",
id: "finalize",
label: "Finalize",
status: { type: "idle" },
},
],
},
@@ -1,12 +1,12 @@
"use client";
import { Button } from "@/components/ui/button";
import {
AlertCircleIcon,
CircleCheckIcon,
CircleIcon,
RefreshCwIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "../../ui/DynamicHeight";
import { Spinner } from "../../ui/Spinner/Spinner";
@@ -32,7 +32,7 @@ export function MultiStepStatus<T extends string>(props: {
<DynamicHeight>
<div className="space-y-4">
{props.steps.map((step) => (
<div key={step.label} className="flex items-start space-x-3 ">
<div className="flex items-start space-x-3 " key={step.label}>
{step.status.type === "completed" ? (
<CircleCheckIcon className="mt-0.5 size-5 flex-shrink-0 text-green-500" />
) : step.status.type === "pending" ? (
@@ -72,10 +72,10 @@ export function MultiStepStatus<T extends string>(props: {
{step.status.message}
</p>
<Button
variant="destructive"
size="sm"
className="gap-2"
onClick={() => props.onRetry(step)}
size="sm"
variant="destructive"
>
<RefreshCwIcon className="size-4" />
Retry
@@ -1,5 +1,7 @@
"use client";
import { BellIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Drawer,
@@ -13,8 +15,6 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useIsMobile } from "@/hooks/use-mobile";
import { BellIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { NotificationList } from "./notification-list";
import { useNotifications } from "./state/manager";
@@ -26,7 +26,7 @@ export function NotificationsButton(props: { accountId: string }) {
const trigger = useMemo(
() => (
<Button variant="outline" size="icon" className="relative rounded-full">
<Button className="relative rounded-full" size="icon" variant="outline">
<BellIcon className="h-4 w-4" />
{(manager.unreadNotificationsCount || 0) > 0 && (
<span className="absolute top-0 right-0 flex h-2 w-2">
@@ -41,7 +41,7 @@ export function NotificationsButton(props: { accountId: string }) {
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<Drawer onOpenChange={setOpen} open={open}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[90vh] min-h-[66vh]">
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
@@ -52,11 +52,11 @@ export function NotificationsButton(props: { accountId: string }) {
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="max-h-[90vh] min-h-[500px] w-[400px] max-w-md p-0"
align="end"
className="max-h-[90vh] min-h-[500px] w-[400px] max-w-md p-0"
>
<NotificationList {...manager} />
</PopoverContent>
@@ -1,7 +1,5 @@
"use client";
import type { Notification } from "@/api/notifications";
import { Button } from "@/components/ui/button";
import {
format,
formatDistanceToNow,
@@ -11,6 +9,8 @@ import {
} from "date-fns";
import { ArchiveIcon } from "lucide-react";
import { useMemo } from "react";
import type { Notification } from "@/api/notifications";
import { Button } from "@/components/ui/button";
interface NotificationEntryProps {
notification: Notification;
@@ -54,11 +54,11 @@ export function NotificationEntry({
<p className="text-muted-foreground text-xs">{timeAgo}</p>
)}
<div className="flex flex-row justify-between gap-2 pt-1">
<Button asChild variant="link" size="sm" className="px-0">
<Button asChild className="px-0" size="sm" variant="link">
<a
href={notification.ctaUrl}
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
{notification.ctaText}
</a>
@@ -68,10 +68,10 @@ export function NotificationEntry({
</div>
{onMarkAsRead && (
<Button
variant="ghost"
size="icon"
onClick={() => onMarkAsRead(notification.id)}
className="text-muted-foreground hover:text-foreground"
onClick={() => onMarkAsRead(notification.id)}
size="icon"
variant="ghost"
>
<ArchiveIcon className="h-4 w-4" />
</Button>
@@ -1,7 +1,7 @@
"use client";
import { TabButtons } from "@/components/ui/tabs";
import { ArchiveIcon, BellIcon, Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { TabButtons } from "@/components/ui/tabs";
import { Badge } from "../../ui/badge";
import { NotificationEntry } from "./notification-entry";
import type { useNotifications } from "./state/manager";
@@ -23,10 +23,11 @@ export function NotificationList(props: ReturnType<typeof useNotifications>) {
return (
<div className="flex w-full flex-1 flex-col">
<TabButtons
tabContainerClassName="space-x-6 px-4 pt-2"
tabClassName="!text-sm hover:!bg-transparent !px-0 !py-1"
tabContainerClassName="space-x-6 px-4 pt-2"
tabs={[
{
isActive: activeTab === "inbox",
name: (
<div className="flex items-center gap-1">
Inbox
@@ -38,12 +39,11 @@ export function NotificationList(props: ReturnType<typeof useNotifications>) {
</div>
),
onClick: () => setActiveTab("inbox"),
isActive: activeTab === "inbox",
},
{
isActive: activeTab === "archive",
name: "Archive",
onClick: () => setActiveTab("archive"),
isActive: activeTab === "archive",
},
]}
/>
@@ -54,21 +54,21 @@ export function NotificationList(props: ReturnType<typeof useNotifications>) {
>
{activeTab === "inbox" ? (
<InboxTab
unreadNotifications={props.unreadNotifications}
isLoadingUnread={props.isLoadingUnread}
hasMoreUnread={props.hasMoreUnread}
isFetchingMoreUnread={props.isFetchingMoreUnread}
isLoadingUnread={props.isLoadingUnread}
loadMoreUnread={props.loadMoreUnread}
markAsRead={props.markAsRead}
markAllAsRead={props.markAllAsRead}
markAsRead={props.markAsRead}
scrollContainerRef={scrollContainerRef}
unreadNotifications={props.unreadNotifications}
/>
) : (
<ArchiveTab
archivedNotifications={props.archivedNotifications}
isLoadingArchived={props.isLoadingArchived}
hasMoreArchived={props.hasMoreArchived}
isFetchingMoreArchived={props.isFetchingMoreArchived}
isLoadingArchived={props.isLoadingArchived}
loadMoreArchived={props.loadMoreArchived}
scrollContainerRef={scrollContainerRef}
/>
@@ -110,8 +110,8 @@ function InboxTab(
))}
<AutoLoadMore
hasMore={props.hasMoreUnread}
loadMore={props.loadMoreUnread}
isLoading={props.isFetchingMoreUnread || props.isLoadingUnread}
loadMore={props.loadMoreUnread}
scrollContainerRef={props.scrollContainerRef}
/>
</>
@@ -144,8 +144,8 @@ function ArchiveTab(
))}
<AutoLoadMore
hasMore={props.hasMoreArchived}
loadMore={props.loadMoreArchived}
isLoading={props.isFetchingMoreArchived || props.isLoadingArchived}
loadMore={props.loadMoreArchived}
scrollContainerRef={props.scrollContainerRef}
/>
</>
@@ -198,5 +198,5 @@ function AutoLoadMore(props: {
);
}
return <div ref={ref} className="h-[1px] w-full opacity-0" aria-hidden />;
return <div aria-hidden className="h-[1px] w-full opacity-0" ref={ref} />;
}
@@ -1,13 +1,5 @@
"use client";
import {
type Notification,
type NotificationsApiResponse,
getArchivedNotifications,
getUnreadNotifications,
getUnreadNotificationsCount,
markNotificationAsRead,
} from "@/api/notifications";
import {
type InfiniteData,
useInfiniteQuery,
@@ -15,9 +7,16 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useMemo } from "react";
import { toast } from "sonner";
import {
getArchivedNotifications,
getUnreadNotifications,
getUnreadNotificationsCount,
markNotificationAsRead,
type Notification,
type NotificationsApiResponse,
} from "@/api/notifications";
/**
* Internal helper to safely flatten pages coming from useInfiniteQuery.
@@ -68,7 +67,9 @@ export function useNotifications(accountId: string) {
);
const unreadQuery = useInfiniteQuery<NotificationsApiResponse>({
queryKey: unreadQueryKey,
enabled: !!accountId,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
const cursor = (pageParam ?? undefined) as string | undefined;
const res = await getUnreadNotifications(cursor);
@@ -77,14 +78,14 @@ export function useNotifications(accountId: string) {
}
return res.data;
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
enabled: !!accountId,
queryKey: unreadQueryKey,
refetchInterval: 60_000, // 1min
});
const archivedQuery = useInfiniteQuery<NotificationsApiResponse>({
queryKey: archivedQueryKey,
enabled: !!accountId,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
const cursor = (pageParam ?? undefined) as string | undefined;
const res = await getArchivedNotifications(cursor);
@@ -93,14 +94,12 @@ export function useNotifications(accountId: string) {
}
return res.data;
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
enabled: !!accountId,
queryKey: archivedQueryKey,
refetchInterval: 60_000, // 1min
});
const unreadCountQuery = useQuery({
queryKey: unreadCountKey,
enabled: !!accountId,
queryFn: async () => {
const res = await getUnreadNotificationsCount();
if (res.status === "error") {
@@ -108,8 +107,8 @@ export function useNotifications(accountId: string) {
}
return res.data.result.unreadCount;
},
refetchInterval: 60_000, // 1min
enabled: !!accountId,
queryKey: unreadCountKey, // 1min
refetchInterval: 60_000,
});
// --------------------
@@ -172,16 +171,7 @@ export function useNotifications(accountId: string) {
queryClient.setQueryData(unreadCountKey, optimisticCount);
}
return { previousUnread, previousCount } as const;
},
// Rollback on error
onError: (_err, _vars, context) => {
if (context?.previousUnread) {
queryClient.setQueryData(unreadQueryKey, context.previousUnread);
}
if (typeof context?.previousCount === "number") {
queryClient.setQueryData(unreadCountKey, context.previousCount);
}
return { previousCount, previousUnread } as const;
},
// Always refetch to ensure consistency
onSettled: () => {
@@ -199,25 +189,25 @@ export function useNotifications(accountId: string) {
const unreadNotificationsCount = unreadCountQuery.data ?? 0; // this is the total unread count
return {
// data
unreadNotifications,
archivedNotifications,
unreadNotificationsCount,
hasMoreArchived: archivedQuery.hasNextPage ?? false,
hasMoreUnread: unreadQuery.hasNextPage ?? false,
isFetchingMoreArchived: archivedQuery.isFetchingNextPage,
isFetchingMoreUnread: unreadQuery.isFetchingNextPage,
isLoadingArchived: archivedQuery.isLoading,
// booleans
isLoadingUnread: unreadQuery.isLoading,
isLoadingArchived: archivedQuery.isLoading,
hasMoreUnread: unreadQuery.hasNextPage ?? false,
hasMoreArchived: archivedQuery.hasNextPage ?? false,
isFetchingMoreUnread: unreadQuery.isFetchingNextPage,
isFetchingMoreArchived: archivedQuery.isFetchingNextPage,
loadMoreArchived: () => archivedQuery.fetchNextPage(),
// pagination helpers
loadMoreUnread: () => unreadQuery.fetchNextPage(),
loadMoreArchived: () => archivedQuery.fetchNextPage(),
markAllAsRead: () => archiveMutation.mutate(undefined),
// mutations
markAsRead: (id: string) => archiveMutation.mutate(id),
markAllAsRead: () => archiveMutation.mutate(undefined),
// data
unreadNotifications,
unreadNotificationsCount,
} as const;
}
@@ -1,15 +1,15 @@
"use client";
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { RenewSubscriptionButton } from "components/settings/Account/Billing/renew-subscription/renew-subscription-button";
import { CheckIcon, DollarSignIcon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { remainingDays } from "utils/date-utils";
import { TEAM_PLANS } from "utils/pricing";
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ProductSKU } from "../../lib/billing";
import { CheckoutButton } from "../billing";
@@ -141,18 +141,18 @@ export const PricingCard: React.FC<PricingCardProps> = ({
{cta && (
<div className="flex flex-col gap-3">
{cta.type === "renew" && (
<RenewSubscriptionButton teamId={teamId} getTeam={getTeam} />
<RenewSubscriptionButton getTeam={getTeam} teamId={teamId} />
)}
{billingPlanToSkuMap[billingPlan] && cta.type === "checkout" && (
<CheckoutButton
billingStatus={billingStatus}
buttonProps={{
variant: highlighted ? "default" : "outline",
className: highlighted ? undefined : "bg-background",
onClick: cta.onClick,
variant: highlighted ? "default" : "outline",
}}
teamSlug={teamSlug}
sku={billingPlanToSkuMap[billingPlan]}
teamSlug={teamSlug}
>
{has7DayTrial ? "Start 7 Day Free Trial" : cta.label}
</CheckoutButton>
@@ -160,15 +160,15 @@ export const PricingCard: React.FC<PricingCardProps> = ({
{cta.type === "link" && (
<Button
variant={highlighted ? "default" : "outline"}
className={highlighted ? undefined : "bg-background"}
asChild
className={highlighted ? undefined : "bg-background"}
variant={highlighted ? "default" : "outline"}
>
<Link
href={cta.href}
target="_blank"
onClick={cta.onClick}
rel="noopener noreferrer"
target="_blank"
>
{has7DayTrial ? "Start 7 Day Free Trial" : cta.label}
</Link>
@@ -188,14 +188,14 @@ export const PricingCard: React.FC<PricingCardProps> = ({
const billingPlanToSkuMap: Record<Team["billingPlan"], ProductSKU | undefined> =
{
starter: "plan:starter",
growth: "plan:growth",
scale: "plan:scale",
pro: "plan:pro",
// we can't render checkout buttons for these plans:
accelerate: undefined,
free: undefined,
growth: "plan:growth",
growth_legacy: undefined,
pro: "plan:pro",
scale: "plan:scale",
starter: "plan:starter",
};
type FeatureItemProps = {
@@ -4,13 +4,13 @@ import { BadgeContainer } from "../../../stories/utils";
import { SelectWithSearch } from "./select-with-search";
const meta = {
title: "blocks/SelectWithSearch",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/SelectWithSearch",
} satisfies Meta<typeof Story>;
export default meta;
@@ -22,20 +22,20 @@ export const Variants: Story = {
function createList(len: number) {
return Array.from({ length: len }, (_, i) => ({
value: `${i}`,
label: `Item ${i}`,
value: `${i}`,
}));
}
function Story() {
return (
<div className="container flex max-w-6xl flex-col gap-6 py-10">
<VariantTest storyLabel="5 items" listLen={5} />
<VariantTest storyLabel="5000 items" listLen={5000} />
<VariantTest listLen={5} storyLabel="5 items" />
<VariantTest listLen={5000} storyLabel="5000 items" />
<VariantTest
defaultValue={"3"}
storyLabel="20 items, 3 selected by default"
listLen={20}
storyLabel="20 items, 3 selected by default"
/>
</div>
);
@@ -52,10 +52,10 @@ function VariantTest(props: {
return (
<BadgeContainer label={props.storyLabel}>
<SelectWithSearch
value={value}
options={list}
onValueChange={setValue}
options={list}
placeholder="Select items"
value={value}
/>
</BadgeContainer>
);
@@ -1,6 +1,7 @@
/* eslint-disable no-restricted-syntax */
"use client";
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
import React, { useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
@@ -8,11 +9,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
import React, { useRef, useMemo, useEffect } from "react";
import { useShowMore } from "../../lib/useShowMore";
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
import { Input } from "../ui/input";
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
interface SelectWithSearchProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -101,28 +100,18 @@ export const SelectWithSearch = React.forwardRef<
// scroll to top when options change
const popoverElRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}, [searchValue]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={() => setIsPopoverOpen(true)}
className={cn(
"flex w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
className,
)}
onClick={() => setIsPopoverOpen(true)}
>
<div className="flex w-full items-center justify-between gap-2">
<span
@@ -141,32 +130,41 @@ export const SelectWithSearch = React.forwardRef<
</PopoverTrigger>
<PopoverContent
className={cn("p-0", popoverContentClassName)}
align={props.align || "center"}
className={cn("p-0", popoverContentClassName)}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
ref={popoverElRef}
side={props.side}
sideOffset={10}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
style={{
width: "var(--radix-popover-trigger-width)",
maxHeight: "var(--radix-popover-content-available-height)",
width: "var(--radix-popover-trigger-width)",
}}
ref={popoverElRef}
>
<div>
{/* Search */}
<div className="relative">
<Input
className="!h-auto rounded-b-none border-0 border-border border-b py-3 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => {
setSearchValue(e.target.value);
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}}
placeholder={searchPlaceholder || "Search"}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="!h-auto rounded-b-none border-0 border-border border-b py-3 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
</div>
<ScrollShadow
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
className="rounded"
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
>
{/* List */}
<div>
@@ -180,20 +178,21 @@ export const SelectWithSearch = React.forwardRef<
const isSelected = value === option.value;
return (
<Button
key={option.value}
role="option"
aria-selected={isSelected}
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
key={option.value}
onClick={() => {
onValueChange(option.value);
if (closeOnSelect) {
setIsPopoverOpen(false);
}
}}
variant="ghost"
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
ref={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
// biome-ignore lint/a11y/useSemanticElements: TDOO
role="option"
variant="ghost"
>
{showCheck && (
<div className="flex size-4 items-center justify-center">
@@ -3,11 +3,7 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { cn } from "../../../lib/utils";
export function GenericLoadingPage({
className,
}: {
className?: string;
}) {
export function GenericLoadingPage({ className }: { className?: string }) {
return (
<div
className={cn(
@@ -1,5 +1,8 @@
"use client";
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,9 +12,6 @@ import {
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { TeamPlanBadge } from "../../../app/(app)/components/TeamPlanBadge";
import type { Team } from "../../api/team";
import { Badge } from "../ui/badge";
@@ -69,8 +69,8 @@ export function UpsellWrapper({
<div className="space-y-2">
<TeamPlanBadge
plan="scale"
teamSlug={teamSlug}
postfix=" Feature"
teamSlug={teamSlug}
/>
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
Unlock {featureName}
@@ -90,15 +90,15 @@ export function UpsellWrapper({
<div className="grid gap-2">
{benefits.map((benefit) => (
<div
key={benefit.description}
className="flex items-center gap-3"
key={benefit.description}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
<SparklesIcon className="h-3 w-3 text-success-text" />
</div>
<span className="text-sm">{benefit.description}</span>
{benefit.status === "soon" && (
<Badge variant="secondary" className="text-xs">
<Badge className="text-xs" variant="secondary">
Coming Soon
</Badge>
)}
@@ -109,7 +109,7 @@ export function UpsellWrapper({
)}
<div className="flex flex-col gap-3 pt-4 sm:flex-row">
<Button className="flex-1 py-3 font-semibold" size="lg" asChild>
<Button asChild className="flex-1 py-3 font-semibold" size="lg">
<Link
href={`/team/${teamSlug}/~/settings/billing?showPlans=true&highlight=${requiredPlan}`}
>
@@ -118,7 +118,7 @@ export function UpsellWrapper({
<span className="ml-1 capitalize">{requiredPlan}</span>
</Link>
</Button>
<Button variant="outline" size="lg" className="md:flex-1" asChild>
<Button asChild className="md:flex-1" size="lg" variant="outline">
<Link
href={`/team/${teamSlug}/~/settings/billing?showPlans=true`}
>
@@ -1,4 +1,9 @@
"use client";
import { useClipboard } from "hooks/useClipboard";
import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
HoverCard,
@@ -6,12 +11,6 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useClipboard } from "hooks/useClipboard";
import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { type ThirdwebClient, isAddress } from "thirdweb";
import { ZERO_ADDRESS } from "thirdweb";
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
@@ -47,7 +46,7 @@ export function WalletAddress(props: {
if (!isAddress(address)) {
return (
<ToolTipLabel label={address} hoverable>
<ToolTipLabel hoverable label={address}>
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
<div className="flex size-6 items-center justify-center rounded-full border bg-background">
<XIcon className="size-4 text-muted-foreground" />
@@ -70,19 +69,19 @@ export function WalletAddress(props: {
tabIndex={props.preventOpenOnFocus ? -1 : undefined}
>
<Button
onClick={(e) => e.stopPropagation()}
variant="link"
className={cn(
"flex flex-row items-center gap-2 px-0",
props.className,
)}
onClick={(e) => e.stopPropagation()}
variant="link"
>
{address && (
<WalletAvatar
address={address}
iconClassName={props.iconClassName}
profiles={profiles.data || []}
thirdwebClient={props.client}
iconClassName={props.iconClassName}
/>
)}
<span className="cursor-pointer font-mono">
@@ -101,10 +100,10 @@ export function WalletAddress(props: {
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Wallet Address</h3>
<Button
variant="outline"
size="sm"
onClick={onCopy}
className="flex items-center gap-2"
onClick={onCopy}
size="sm"
variant="outline"
>
{hasCopied ? (
<CheckIcon className="h-4 w-4" />
@@ -137,9 +136,9 @@ export function WalletAddress(props: {
{walletAvatarLink && (
<Avatar>
<AvatarImage
src={walletAvatarLink}
alt={profile.name}
className="object-cover"
src={walletAvatarLink}
/>
{profile.name && (
<AvatarFallback>
@@ -197,14 +196,14 @@ function WalletAvatar(props: {
>
{resolvedAvatarSrc ? (
<Img
src={resolvedAvatarSrc}
className={cn("size-6 object-cover", props.iconClassName)}
src={resolvedAvatarSrc}
/>
) : (
<Blobbie
address={props.address}
size={24}
className={props.iconClassName}
size={24}
/>
)}
</div>
@@ -1,9 +1,9 @@
"use client";
import { ClientOnly } from "@/components/blocks/client-only";
import { Button } from "@/components/ui/button";
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { ClientOnly } from "@/components/blocks/client-only";
import { Button } from "@/components/ui/button";
import { Skeleton } from "./ui/skeleton";
export function ToggleThemeButton() {
@@ -14,12 +14,12 @@ export function ToggleThemeButton() {
ssr={<Skeleton className="size-[34px] rounded-full border bg-accent" />}
>
<Button
variant="outline"
aria-label="Toggle theme"
className="h-auto w-auto rounded-full border border-border bg-background p-2"
onClick={() => {
setTheme(theme === "dark" ? "light" : "dark");
}}
aria-label="Toggle theme"
className="h-auto w-auto rounded-full border border-border bg-background p-2"
variant="outline"
>
{theme === "light" ? (
<SunIcon className="size-4" />
@@ -4,13 +4,13 @@ import { BadgeContainer } from "stories/utils";
import { PaginationButtons } from "./pagination-buttons";
const meta = {
title: "blocks/PaginationButtons",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/PaginationButtons",
} satisfies Meta<typeof Story>;
export default meta;
@@ -32,10 +32,7 @@ function Story() {
);
}
function Variant(props: {
label: string;
totalPages: number;
}) {
function Variant(props: { label: string; totalPages: number }) {
const [activePage, setActivePage] = useState(1);
return (
<BadgeContainer label={props.label}>
@@ -1,5 +1,7 @@
"use client";
import { ArrowUpRightIcon } from "lucide-react";
import { useState } from "react";
import {
Pagination,
PaginationContent,
@@ -9,8 +11,6 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { ArrowUpRightIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -179,27 +179,27 @@ export const PaginationButtons = (props: {
<div className="relative flex items-center">
<Input
value={pageNumberInput}
onChange={(e) => {
setInputHasError(false);
setPageNumberInput(e.target.value);
}}
type="number"
placeholder="Page"
className={cn(
"w-[60px] bg-transparent [appearance:textfield] max-sm:placeholder:text-sm lg:w-[100px] lg:pr-8 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
inputHasError && "text-red-500",
)}
onChange={(e) => {
setInputHasError(false);
setPageNumberInput(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handlePageInputSubmit();
}
}}
placeholder="Page"
type="number"
value={pageNumberInput}
/>
<Button
variant="ghost"
className="absolute right-1 h-auto w-auto p-2 max-sm:hidden"
onClick={handlePageInputSubmit}
variant="ghost"
>
<ArrowUpRightIcon className="size-4" />
</Button>
@@ -1,3 +1,5 @@
import type { VariantProps } from "class-variance-authority";
import type React from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -11,8 +13,6 @@ import {
} from "@/components/ui/alert-dialog";
import type { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
import type React from "react";
export interface ConfirmationDialogProps
extends VariantProps<typeof buttonVariants> {
@@ -36,7 +36,7 @@ export function ConfirmationDialog({
className,
}: ConfirmationDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent className={cn("w-[90vw] sm:w-auto", className)}>
@@ -48,12 +48,12 @@ export function ConfirmationDialog({
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant={variant}
type="submit"
onClick={() => {
onSubmit?.();
onOpenChange?.(false);
}}
type="submit"
variant={variant}
>
Continue
</AlertDialogAction>
@@ -20,12 +20,12 @@ export function CopyAddressButton(props: {
return (
<CopyTextButton
className={cn("font-mono text-sm", props.className)}
copyIconPosition={props.copyIconPosition}
textToCopy={props.address}
textToShow={shortenedAddress}
tooltip="Copy Address"
className={cn("font-mono text-sm", props.className)}
variant={props.variant}
copyIconPosition={props.copyIconPosition}
/>
);
}
@@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/utils";
import { useClipboard } from "hooks/useClipboard";
import { CheckIcon, CopyIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { ToolTipLabel } from "./tooltip";
@@ -16,10 +16,10 @@ export function CopyButton(props: {
return (
<ToolTipLabel label="Copy">
<Button
variant={props.variant || "ghost"}
aria-label="Copy"
className={cn("h-auto w-auto p-1", props.className)}
onClick={onCopy}
variant={props.variant || "ghost"}
>
{hasCopied ? (
<CheckIcon
@@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/utils";
import { useClipboard } from "hooks/useClipboard";
import { CheckIcon, CopyIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { ToolTipLabel } from "./tooltip";
@@ -39,7 +39,6 @@ export function CopyTextButton(props: {
return (
<ToolTipLabel label={props.tooltip}>
<Button
variant={props.variant || "outline"}
aria-label={props.tooltip}
className={cn(
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground",
@@ -50,6 +49,7 @@ export function CopyTextButton(props: {
e.stopPropagation();
props.onClick?.();
}}
variant={props.variant || "outline"}
>
{props.copyIconPosition === "right" ? (
<>
@@ -1,5 +1,8 @@
"use client";
import { format, isBefore } from "date-fns";
import { CalendarIcon, CalendarX2Icon, ChevronDownIcon } from "lucide-react";
import React from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
@@ -8,9 +11,6 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { format, isBefore } from "date-fns";
import { CalendarIcon, CalendarX2Icon, ChevronDownIcon } from "lucide-react";
import React from "react";
import { DynamicHeight } from "./DynamicHeight";
import { TabButtons } from "./tabs";
@@ -33,23 +33,22 @@ export function DatePickerWithRange(props: {
return (
<Popover
open={isOpen}
onOpenChange={(v) => {
if (!v) {
setScreen("from");
}
setIsOpen(v);
}}
open={isOpen}
>
{/* Button */}
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"justify-start gap-2 text-left font-normal",
props.className,
)}
variant="outline"
>
<CalendarIcon className="h-4 w-4" />
{props.labelOverride || (
@@ -63,8 +62,8 @@ export function DatePickerWithRange(props: {
{/* Popover */}
<PopoverContent
className="w-auto p-0"
align={props.popoverAlign || "start"}
className="w-auto p-0"
sideOffset={10}
>
<DynamicHeight>
@@ -79,19 +78,19 @@ export function DatePickerWithRange(props: {
<div className={cn("px-4", !props.header && "py-4")}>
<TabButtons
tabClassName="!text-sm"
activeTabClassName="!bg-inverted !text-inverted-foreground"
tabClassName="!text-sm"
tabContainerClassName="gap-2"
tabs={[
{
isActive: screen === "from",
name: "From",
onClick: () => setScreen("from"),
isActive: screen === "from",
},
{
isActive: screen === "to",
name: "To",
onClick: () => setScreen("to"),
isActive: screen === "to",
},
]}
/>
@@ -99,44 +98,44 @@ export function DatePickerWithRange(props: {
{screen === "from" && (
<Calendar
key={from.toString()}
mode="range"
selected={{
from,
to,
classNames={{
day_range_end:
"!bg-inverted/50 !text-inverted-foreground pointer-events-none",
day_range_start: "!bg-inverted !text-inverted-foreground",
}}
defaultMonth={from}
key={from.toString()}
mode="range"
onDayClick={(newFrom) => {
if (isBefore(newFrom, to)) {
setFrom(newFrom);
}
}}
classNames={{
day_range_start: "!bg-inverted !text-inverted-foreground",
day_range_end:
"!bg-inverted/50 !text-inverted-foreground pointer-events-none",
selected={{
from,
to,
}}
/>
)}
{screen === "to" && (
<Calendar
key={to.toString()}
mode="range"
selected={{
from,
to,
classNames={{
day_range_end: "!bg-inverted !text-inverted-foreground",
day_range_start:
"!bg-inverted/50 !text-inverted-foreground pointer-events-none",
}}
defaultMonth={to}
key={to.toString()}
mode="range"
onDayClick={(newTo) => {
if (isBefore(from, newTo)) {
setTo(newTo);
}
}}
classNames={{
day_range_end: "!bg-inverted !text-inverted-foreground",
day_range_start:
"!bg-inverted/50 !text-inverted-foreground pointer-events-none",
selected={{
from,
to,
}}
/>
)}

Some files were not shown because too many files have changed in this diff Show More