Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14a78c06c | ||
|
|
f81fd46e9c | ||
|
|
d6ce3aedd8 | ||
|
|
6672549edd | ||
|
|
639b542d27 | ||
|
|
b5fb5dd0cc | ||
|
|
8b036dcfa7 | ||
|
|
56bc4ae335 | ||
|
|
71f83a884b | ||
|
|
2d5ff74335 | ||
|
|
cbc3bf1d36 | ||
|
|
20ead112e2 | ||
|
|
06d25bb7a9 | ||
|
|
91f8b2dbf2 | ||
|
|
9901351fb2 | ||
|
|
cb86fa2929 | ||
|
|
53718208cd | ||
|
|
ef2613aca5 | ||
|
|
f2e61b83cb | ||
|
|
ab0d33016d | ||
|
|
f54a0cb39b | ||
|
|
90010ee723 | ||
|
|
269fbe5ecf | ||
|
|
7ed8fc2c43 | ||
|
|
9f2f2e794a | ||
|
|
eefd2649fd | ||
|
|
62de6dd04c | ||
|
|
6fc6c6e681 | ||
|
|
58cd79426d | ||
|
|
185e8f48df | ||
|
|
d8865b74ef | ||
|
|
1e8d1e4b52 | ||
|
|
91194ce63a | ||
|
|
a1141f316f | ||
|
|
1273cc7b83 | ||
|
|
691110286d | ||
|
|
4d197a822d | ||
|
|
f655657fce | ||
|
|
109f250068 | ||
|
|
f7cf09b3cb | ||
|
|
dd06be8def | ||
|
|
bf8da3588f | ||
|
|
f6cbdb7e30 | ||
|
|
d350f8cb13 | ||
|
|
4b200503b8 | ||
|
|
f8eb86da47 | ||
|
|
2ae778ed7f | ||
|
|
1c135b0f65 | ||
|
|
286114ab81 | ||
|
|
cbc4e80330 | ||
|
|
efceadd3c1 | ||
|
|
c653d7033d | ||
|
|
77029a2e01 | ||
|
|
b0b3b7f8f6 | ||
|
|
617fe68c32 | ||
|
|
01d0666208 | ||
|
|
621357a5c6 | ||
|
|
0527ee26b7 | ||
|
|
834ed8337b | ||
|
|
11bb4f5fee |
@@ -6,15 +6,15 @@ runs:
|
||||
steps:
|
||||
# we use bun for some test suites
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
|
||||
with:
|
||||
bun-version: 1.0.35
|
||||
# pnpm for our dependencies
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
with:
|
||||
version: 9
|
||||
version: 9.11.0
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
|
||||
+10
-10
@@ -26,7 +26,7 @@ jobs:
|
||||
steps:
|
||||
- name: Optimize CI
|
||||
id: check_skip
|
||||
uses: withgraphite/graphite-ci-action@main
|
||||
uses: withgraphite/graphite-ci-action@9cb601a55e114099561b6d755505de377d45db40 # v0.0.9 ("main")
|
||||
with:
|
||||
graphite_token: ${{ secrets.GRAPHITE_OMTIMIZE_TOKEN }}
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Build Packages
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup & Install
|
||||
uses: ./.github/composite-actions/install
|
||||
@@ -54,13 +54,13 @@ jobs:
|
||||
runs-on: ubuntu-latest-8
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup & Install
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@v2
|
||||
uses: biomejs/setup-biome@c016c38f26f2c4a6eb3662679143614a254263fd # v2.3.0
|
||||
with:
|
||||
version: latest
|
||||
|
||||
@@ -74,13 +74,13 @@ jobs:
|
||||
runs-on: ubuntu-latest-8
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup & Install
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Set up foundry
|
||||
uses: foundry-rs/foundry-toolchain@v1
|
||||
uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # v1.3.1
|
||||
with:
|
||||
cache: false
|
||||
version: nightly-c4a984fbf2c48b793c8cd53af84f56009dd1070c
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- run: pnpm test
|
||||
|
||||
- name: Code Coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
|
||||
with:
|
||||
directory: packages/
|
||||
flags: packages
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
bundler: [vite, webpack, esbuild]
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup & Install
|
||||
uses: ./.github/composite-actions/install
|
||||
@@ -172,13 +172,13 @@ jobs:
|
||||
runs-on: ubuntu-latest-8
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup & Install
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Report bundle size
|
||||
uses: andresz1/size-limit-action@master
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
package_manager: pnpm
|
||||
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
github.event.pull_request.author_association == 'COLLABORATOR' ||
|
||||
github.event.pull_request.author_association == 'CONTRIBUTOR'
|
||||
steps:
|
||||
- uses: toshimaru/[email protected]
|
||||
- uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
|
||||
|
||||
@@ -42,11 +42,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout branch
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Do not use the GITHUB_TOKEN by default
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout branch
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Do not use the GITHUB_TOKEN by default
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Create release Pull Request or publish to NPM
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
uses: changesets/action@06245a4e0a36c064a573d4150030f5ec548e4fcc #v1.4.0
|
||||
with:
|
||||
publish: pnpm release
|
||||
version: pnpm version-packages
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v.9.1.0
|
||||
with:
|
||||
stale-issue-message: 'This issue has been inactive for 7 days. It is now marked as stale and will be closed in 2 days if no further activity occurs.'
|
||||
stale-pr-message: 'This PR has been inactive for 7 days. It is now marked as stale and will be closed in 2 days if no further activity occurs.'
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install
|
||||
uses: ./.github/composite-actions/install
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
run: pnpm typedoc
|
||||
|
||||
- name: Update Gist
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GIST_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -102,3 +102,6 @@ ANALYTICS_SERVICE_URL=""
|
||||
|
||||
# Required for Nebula Chat
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
|
||||
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
|
||||
STRIPE_SECRET_KEY=""
|
||||
@@ -27,5 +27,8 @@ const config: StorybookConfig = {
|
||||
},
|
||||
},
|
||||
staticDirs: ["../public"],
|
||||
features: {
|
||||
experimentalRSC: true,
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Inter as interFont } from "next/font/google";
|
||||
// biome-ignore lint/style/useImportType: <explanation>
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { Button } from "../src/@/components/ui/button";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -16,8 +17,30 @@ const fontSans = interFont({
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const customViewports = {
|
||||
xs: {
|
||||
// Regular sized phones (iphone 15 / 15 pro)
|
||||
name: "iPhone",
|
||||
styles: {
|
||||
width: "390px",
|
||||
height: "844px",
|
||||
},
|
||||
},
|
||||
sm: {
|
||||
// Larger phones (iphone 15 plus / 15 pro max)
|
||||
name: "iPhone Plus",
|
||||
styles: {
|
||||
width: "430px",
|
||||
height: "932px",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
viewport: {
|
||||
viewports: customViewports,
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
@@ -57,13 +80,13 @@ function StoryLayout(props: {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex min-h-screen min-w-0 flex-col bg-background text-foreground">
|
||||
<div className="flex min-h-dvh min-w-0 flex-col bg-background text-foreground">
|
||||
<div className="flex justify-end gap-2 border-b p-4">
|
||||
<Button
|
||||
onClick={() => setTheme("dark")}
|
||||
size="sm"
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
className="h-auto w-auto rounded-full p-2"
|
||||
className="h-auto w-auto shrink-0 rounded-full p-2"
|
||||
>
|
||||
<MoonIcon className="size-4" />
|
||||
</Button>
|
||||
@@ -72,14 +95,20 @@ function StoryLayout(props: {
|
||||
onClick={() => setTheme("light")}
|
||||
size="sm"
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
className="h-auto w-auto rounded-full p-2"
|
||||
className="h-auto w-auto shrink-0 rounded-full p-2"
|
||||
>
|
||||
<SunIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 grow flex-col">{props.children}</div>
|
||||
<ToasterSetup />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToasterSetup() {
|
||||
const { theme } = useTheme();
|
||||
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
|
||||
}
|
||||
|
||||
+28
-27
@@ -45,9 +45,9 @@
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "1.1.8",
|
||||
"@sentry/nextjs": "9.3.0",
|
||||
"@sentry/nextjs": "9.5.0",
|
||||
"@shazow/whatsabi": "0.20.0",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"@tanstack/react-query": "5.67.3",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/functions": "2.0.0",
|
||||
@@ -61,18 +61,18 @@
|
||||
"date-fns": "4.1.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "12.4.10",
|
||||
"framer-motion": "12.5.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.5.0",
|
||||
"ioredis": "^5.6.0",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.477.0",
|
||||
"next": "15.2.0",
|
||||
"lucide-react": "0.482.0",
|
||||
"next": "15.2.2",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.0",
|
||||
"nuqs": "^2.4.1",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"pluralize": "^8.0.0",
|
||||
@@ -94,7 +94,8 @@
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "2.0.1",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"swagger-ui-react": "^5.20.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.20.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
@@ -104,21 +105,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.5",
|
||||
"@next/bundle-analyzer": "15.2.0",
|
||||
"@next/eslint-plugin-next": "15.2.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@storybook/addon-essentials": "8.6.3",
|
||||
"@storybook/addon-interactions": "8.6.3",
|
||||
"@storybook/addon-links": "8.6.3",
|
||||
"@storybook/addon-onboarding": "8.6.3",
|
||||
"@storybook/addon-viewport": "8.6.3",
|
||||
"@storybook/blocks": "8.6.3",
|
||||
"@storybook/nextjs": "8.6.3",
|
||||
"@storybook/react": "8.6.3",
|
||||
"@storybook/test": "8.6.3",
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@next/bundle-analyzer": "15.2.2",
|
||||
"@next/eslint-plugin-next": "15.2.2",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@storybook/addon-essentials": "8.6.4",
|
||||
"@storybook/addon-interactions": "8.6.4",
|
||||
"@storybook/addon-links": "8.6.4",
|
||||
"@storybook/addon-onboarding": "8.6.4",
|
||||
"@storybook/addon-viewport": "8.6.4",
|
||||
"@storybook/blocks": "8.6.4",
|
||||
"@storybook/nextjs": "8.6.4",
|
||||
"@storybook/react": "8.6.4",
|
||||
"@storybook/test": "8.6.4",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/node": "22.13.10",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -129,16 +130,16 @@
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"checkly": "5.0.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||
"eslint-plugin-storybook": "0.11.3",
|
||||
"knip": "5.45.0",
|
||||
"eslint-plugin-storybook": "0.11.6",
|
||||
"knip": "5.46.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.3",
|
||||
"storybook": "8.6.3",
|
||||
"storybook": "8.6.4",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
|
||||
@@ -354,6 +354,13 @@ async function redirects() {
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
|
||||
export type RedirectCheckoutOptions = {
|
||||
export type GetBillingCheckoutUrlOptions = {
|
||||
teamSlug: string;
|
||||
sku: ProductSKU;
|
||||
redirectUrl: string;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function redirectToCheckout(
|
||||
options: RedirectCheckoutOptions,
|
||||
): Promise<{ status: number }> {
|
||||
export async function getBillingCheckoutUrl(
|
||||
options: GetBillingCheckoutUrlOptions,
|
||||
): Promise<{ status: number; url?: string }> {
|
||||
if (!options.teamSlug) {
|
||||
return {
|
||||
status: 400,
|
||||
@@ -49,6 +48,7 @@ export async function redirectToCheckout(
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!json.result) {
|
||||
return {
|
||||
@@ -56,20 +56,22 @@ export async function redirectToCheckout(
|
||||
};
|
||||
}
|
||||
|
||||
// redirect to the stripe checkout session
|
||||
redirect(json.result);
|
||||
return {
|
||||
status: 200,
|
||||
url: json.result as string,
|
||||
};
|
||||
}
|
||||
|
||||
export type RedirectBillingCheckoutAction = typeof redirectToCheckout;
|
||||
export type GetBillingCheckoutUrlAction = typeof getBillingCheckoutUrl;
|
||||
|
||||
export type BillingPortalOptions = {
|
||||
export type GetBillingPortalUrlOptions = {
|
||||
teamSlug: string | undefined;
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export async function redirectToBillingPortal(
|
||||
options: BillingPortalOptions,
|
||||
): Promise<{ status: number }> {
|
||||
export async function getBillingPortalUrl(
|
||||
options: GetBillingPortalUrlOptions,
|
||||
): Promise<{ status: number; url?: string }> {
|
||||
if (!options.teamSlug) {
|
||||
return {
|
||||
status: 400,
|
||||
@@ -110,8 +112,10 @@ export async function redirectToBillingPortal(
|
||||
};
|
||||
}
|
||||
|
||||
// redirect to the stripe billing portal
|
||||
redirect(json.result);
|
||||
return {
|
||||
status: 200,
|
||||
url: json.result as string,
|
||||
};
|
||||
}
|
||||
|
||||
export type BillingBillingPortalAction = typeof redirectToBillingPortal;
|
||||
export type GetBillingPortalUrlAction = typeof getBillingPortalUrl;
|
||||
|
||||
@@ -5,13 +5,14 @@ import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
type ProxyActionParams = {
|
||||
pathname: string;
|
||||
searchParams?: Record<string, string>;
|
||||
searchParams?: Record<string, string | undefined>;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
parseAsText?: boolean;
|
||||
};
|
||||
|
||||
type ProxyActionResult<T extends object> =
|
||||
type ProxyActionResult<T> =
|
||||
| {
|
||||
status: number;
|
||||
ok: true;
|
||||
@@ -23,7 +24,7 @@ type ProxyActionResult<T extends object> =
|
||||
error: string;
|
||||
};
|
||||
|
||||
async function proxy<T extends object>(
|
||||
async function proxy<T>(
|
||||
baseUrl: string,
|
||||
params: ProxyActionParams,
|
||||
): Promise<ProxyActionResult<T>> {
|
||||
@@ -34,7 +35,10 @@ async function proxy<T extends object>(
|
||||
url.pathname = params.pathname;
|
||||
if (params.searchParams) {
|
||||
for (const key in params.searchParams) {
|
||||
url.searchParams.append(key, params.searchParams[key] as string);
|
||||
const value = params.searchParams[key];
|
||||
if (value) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,19 +71,15 @@ async function proxy<T extends object>(
|
||||
return {
|
||||
status: res.status,
|
||||
ok: true,
|
||||
data: await res.json(),
|
||||
data: params.parseAsText ? await res.text() : await res.json(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiServerProxy<T extends object = object>(
|
||||
params: ProxyActionParams,
|
||||
) {
|
||||
export async function apiServerProxy<T>(params: ProxyActionParams) {
|
||||
return proxy<T>(API_SERVER_URL, params);
|
||||
}
|
||||
|
||||
export async function payServerProxy<T extends object = object>(
|
||||
params: ProxyActionParams,
|
||||
) {
|
||||
export async function payServerProxy<T>(params: ProxyActionParams) {
|
||||
return proxy<T>(
|
||||
process.env.NEXT_PUBLIC_PAY_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_PAY_URL}`
|
||||
@@ -87,3 +87,7 @@ export async function payServerProxy<T extends object = object>(
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function analyticsServerProxy<T>(params: ProxyActionParams) {
|
||||
return proxy<T>(process.env.ANALYTICS_SERVICE_URL || "", params);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import "server-only";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import type { Team } from "../api/team";
|
||||
|
||||
let existingStripe: Stripe | undefined;
|
||||
|
||||
function getStripe() {
|
||||
if (!existingStripe) {
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (!STRIPE_SECRET_KEY) {
|
||||
throw new Error("STRIPE_SECRET_KEY is not set");
|
||||
}
|
||||
|
||||
existingStripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2025-02-24.acacia",
|
||||
});
|
||||
}
|
||||
|
||||
return existingStripe;
|
||||
}
|
||||
|
||||
export async function getTeamInvoices(
|
||||
team: Team,
|
||||
options?: { cursor?: string },
|
||||
) {
|
||||
try {
|
||||
const customerId = team.stripeCustomerId;
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error("No customer ID found");
|
||||
}
|
||||
|
||||
// Get the list of invoices for the customer
|
||||
const invoices = await getStripe().invoices.list({
|
||||
customer: customerId,
|
||||
limit: 10,
|
||||
starting_after: options?.cursor,
|
||||
});
|
||||
|
||||
return invoices;
|
||||
} catch (error) {
|
||||
console.error("Error fetching billing history:", error);
|
||||
|
||||
// If the error is that the customer doesn't exist, return an empty array
|
||||
// instead of throwing an error
|
||||
if (
|
||||
error instanceof Stripe.errors.StripeError &&
|
||||
error.message.includes("No such customer")
|
||||
) {
|
||||
return {
|
||||
data: [],
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch billing history");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "../constants/env";
|
||||
|
||||
export type SMSCountryTiers = {
|
||||
tier1: string[];
|
||||
tier2: string[];
|
||||
tier3: string[];
|
||||
tier4: string[];
|
||||
tier5: string[];
|
||||
};
|
||||
|
||||
export async function getSMSCountryTiers() {
|
||||
if (!THIRDWEB_API_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/sms/list-country-tiers`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": THIRDWEB_API_SECRET,
|
||||
},
|
||||
next: {
|
||||
revalidate: 15 * 60, //15 minutes
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to fetch sms country tiers",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
res.body?.cancel();
|
||||
return {
|
||||
tier1: [],
|
||||
tier2: [],
|
||||
tier3: [],
|
||||
tier4: [],
|
||||
tier5: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()).data as SMSCountryTiers;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse sms country tiers", e);
|
||||
return {
|
||||
tier1: [],
|
||||
tier2: [],
|
||||
tier3: [],
|
||||
tier4: [],
|
||||
tier5: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
|
||||
import type { TeamResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
export type Team = TeamResponse;
|
||||
export type Team = TeamResponse & { stripeCustomerId: string | null };
|
||||
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TextDivider(props: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center text-muted-foreground text-sm",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<span className="h-[1px] flex-1 bg-border" />
|
||||
<span className="mx-4">{props.text}</span>
|
||||
<span className="h-[1px] flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
BillingBillingPortalAction,
|
||||
BillingPortalOptions,
|
||||
RedirectBillingCheckoutAction,
|
||||
RedirectCheckoutOptions,
|
||||
GetBillingCheckoutUrlAction,
|
||||
GetBillingCheckoutUrlOptions,
|
||||
GetBillingPortalUrlAction,
|
||||
GetBillingPortalUrlOptions,
|
||||
} from "../actions/billing";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Spinner } from "./ui/Spinner/Spinner";
|
||||
import { Button, type ButtonProps } from "./ui/button";
|
||||
|
||||
type CheckoutButtonProps = Omit<RedirectCheckoutOptions, "redirectUrl"> &
|
||||
ButtonProps & {
|
||||
redirectPath: string;
|
||||
redirectToCheckout: RedirectBillingCheckoutAction;
|
||||
};
|
||||
type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
buttonProps?: Omit<ButtonProps, "children">;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function CheckoutButton({
|
||||
onClick,
|
||||
teamSlug,
|
||||
sku,
|
||||
metadata,
|
||||
redirectPath,
|
||||
getBillingCheckoutUrl,
|
||||
children,
|
||||
redirectToCheckout,
|
||||
...restProps
|
||||
buttonProps,
|
||||
}: CheckoutButtonProps) {
|
||||
const getUrlMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return getBillingCheckoutUrl({
|
||||
teamSlug,
|
||||
sku,
|
||||
metadata,
|
||||
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const errorMessage = "Failed to open checkout page";
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...restProps}
|
||||
{...buttonProps}
|
||||
className={cn(buttonProps?.className, "gap-2")}
|
||||
disabled={getUrlMutation.isPending || buttonProps?.disabled}
|
||||
onClick={async (e) => {
|
||||
onClick?.(e);
|
||||
await redirectToCheckout({
|
||||
teamSlug,
|
||||
sku,
|
||||
metadata,
|
||||
redirectUrl: getRedirectUrl(redirectPath),
|
||||
buttonProps?.onClick?.(e);
|
||||
getUrlMutation.mutate(undefined, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.url) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = window.open(res.url, "_blank");
|
||||
|
||||
if (!tab) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{getUrlMutation.isPending && <Spinner className="size-4" />}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type BillingPortalButtonProps = Omit<BillingPortalOptions, "redirectUrl"> &
|
||||
ButtonProps & {
|
||||
redirectPath: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
};
|
||||
type BillingPortalButtonProps = Omit<
|
||||
GetBillingPortalUrlOptions,
|
||||
"redirectUrl"
|
||||
> & {
|
||||
getBillingPortalUrl: GetBillingPortalUrlAction;
|
||||
buttonProps?: Omit<ButtonProps, "children">;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function BillingPortalButton({
|
||||
onClick,
|
||||
teamSlug,
|
||||
redirectPath,
|
||||
children,
|
||||
redirectToBillingPortal,
|
||||
...restProps
|
||||
getBillingPortalUrl,
|
||||
buttonProps,
|
||||
}: BillingPortalButtonProps) {
|
||||
const getUrlMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return getBillingPortalUrl({
|
||||
teamSlug,
|
||||
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const errorMessage = "Failed to open billing portal";
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...restProps}
|
||||
{...buttonProps}
|
||||
className={cn(buttonProps?.className, "gap-2")}
|
||||
disabled={getUrlMutation.isPending || buttonProps?.disabled}
|
||||
onClick={async (e) => {
|
||||
onClick?.(e);
|
||||
await redirectToBillingPortal({
|
||||
teamSlug,
|
||||
redirectUrl: getRedirectUrl(redirectPath),
|
||||
buttonProps?.onClick?.(e);
|
||||
getUrlMutation.mutate(undefined, {
|
||||
onSuccess(res) {
|
||||
if (!res.url) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = window.open(res.url, "_blank");
|
||||
if (!tab) {
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{getUrlMutation.isPending && <Spinner className="size-4" />}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function getRedirectUrl(path: string) {
|
||||
function getAbsoluteUrl(path: string) {
|
||||
const url = new URL(window.location.origin);
|
||||
url.pathname = path;
|
||||
return url.toString();
|
||||
|
||||
@@ -2,11 +2,12 @@ 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 { cn } from "../../lib/utils";
|
||||
|
||||
export function ExportToCSVButton(props: {
|
||||
getData: () => Promise<{ header: string[]; rows: string[][] }>;
|
||||
getData: () => Promise<{ header: string[]; rows: string[][] } | string>;
|
||||
fileName: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
@@ -14,7 +15,12 @@ export function ExportToCSVButton(props: {
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const data = await props.getData();
|
||||
exportToCSV(props.fileName, data);
|
||||
if (typeof data === "string") {
|
||||
exportToCSV(props.fileName, data);
|
||||
} else {
|
||||
const fileContent = convertToCSVFormat(data);
|
||||
exportToCSV(props.fileName, fileContent);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to download CSV");
|
||||
@@ -29,7 +35,7 @@ export function ExportToCSVButton(props: {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={props.disabled}
|
||||
disabled={props.disabled || exportMutation.isPending}
|
||||
className={cn("flex items-center gap-2 border text-xs", props.className)}
|
||||
onClick={async () => {
|
||||
exportMutation.mutate();
|
||||
@@ -50,15 +56,15 @@ export function ExportToCSVButton(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function exportToCSV(
|
||||
fileName: string,
|
||||
data: { header: string[]; rows: string[][] },
|
||||
) {
|
||||
const { header, rows } = data;
|
||||
const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows
|
||||
.map((e) => e.join(","))
|
||||
.join("\n")}`;
|
||||
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
|
||||
return Papa.unparse({
|
||||
fields: data.header,
|
||||
data: data.rows,
|
||||
});
|
||||
}
|
||||
|
||||
function exportToCSV(fileName: string, fileContent: string) {
|
||||
const csvContent = `data:text/csv;charset=utf-8,${fileContent}`;
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Img(props: imgElementProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
{...restProps}
|
||||
// avoid setting empty src string to prevent request to the entire page
|
||||
|
||||
@@ -104,6 +104,7 @@ export function SingleNetworkSelector(props: {
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
disableChainId?: boolean;
|
||||
align?: "center" | "start" | "end";
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
@@ -180,7 +181,11 @@ export function SingleNetworkSelector(props: {
|
||||
props.onChange(Number(chainId));
|
||||
}}
|
||||
closeOnSelect={true}
|
||||
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
|
||||
placeholder={
|
||||
isLoadingChains
|
||||
? "Loading Chains..."
|
||||
: props.placeholder || "Select Chain"
|
||||
}
|
||||
overrideSearchFn={searchFn}
|
||||
renderOption={renderOption}
|
||||
className={props.className}
|
||||
|
||||
@@ -96,7 +96,6 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
onClick: () => setEnvironment(env.environment),
|
||||
isActive: activeEnvironment === env.environment,
|
||||
name: env.title,
|
||||
isEnabled: true,
|
||||
}))}
|
||||
tabClassName="text-sm gap-2 !text-sm"
|
||||
tabIconClassName="size-4"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,7 +9,7 @@ import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { TEAM_PLANS } from "utils/pricing";
|
||||
import { remainingDays } from "../../../utils/date-utils";
|
||||
import type { RedirectBillingCheckoutAction } from "../../actions/billing";
|
||||
import type { GetBillingCheckoutUrlAction } from "../../actions/billing";
|
||||
import { CheckoutButton } from "../billing";
|
||||
|
||||
type ButtonProps = React.ComponentProps<typeof Button>;
|
||||
@@ -27,14 +28,13 @@ type PricingCardProps = {
|
||||
label?: string;
|
||||
};
|
||||
variant?: ButtonProps["variant"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
ctaHint?: string;
|
||||
highlighted?: boolean;
|
||||
current?: boolean;
|
||||
canTrialGrowth?: boolean;
|
||||
activeTrialEndsAt?: string;
|
||||
redirectPath: string;
|
||||
redirectToCheckout: RedirectBillingCheckoutAction;
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
};
|
||||
|
||||
export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
@@ -43,10 +43,8 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
cta,
|
||||
highlighted = false,
|
||||
current = false,
|
||||
canTrialGrowth = false,
|
||||
activeTrialEndsAt,
|
||||
redirectPath,
|
||||
redirectToCheckout,
|
||||
getBillingCheckoutUrl,
|
||||
}) => {
|
||||
const plan = TEAM_PLANS[billingPlan];
|
||||
const isCustomPrice = typeof plan.price === "string";
|
||||
@@ -88,18 +86,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-3xl text-foreground tracking-tight">
|
||||
{isCustomPrice ? (
|
||||
plan.price
|
||||
) : canTrialGrowth ? (
|
||||
<>
|
||||
<span className="text-muted-foreground line-through">
|
||||
${plan.price}
|
||||
</span>{" "}
|
||||
$0
|
||||
</>
|
||||
) : (
|
||||
`$${plan.price}`
|
||||
)}
|
||||
${plan.price}
|
||||
</span>
|
||||
|
||||
{!isCustomPrice && (
|
||||
@@ -135,7 +122,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col items-start gap-2.5 text-foreground">
|
||||
<div className="flex grow flex-col items-start gap-2 text-foreground">
|
||||
{plan.subTitle && (
|
||||
<p className="font-medium text-foreground">{plan.subTitle}</p>
|
||||
)}
|
||||
@@ -149,11 +136,14 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
<div className="flex flex-col gap-3">
|
||||
{billingPlan !== "pro" ? (
|
||||
<CheckoutButton
|
||||
variant={cta.variant || "outline"}
|
||||
buttonProps={{
|
||||
variant: cta.variant || "outline",
|
||||
className: "gap-2",
|
||||
onClick: cta.onClick,
|
||||
}}
|
||||
teamSlug={teamSlug}
|
||||
sku={billingPlan === "starter" ? "plan:starter" : "plan:growth"}
|
||||
redirectPath={redirectPath}
|
||||
redirectToCheckout={redirectToCheckout}
|
||||
getBillingCheckoutUrl={getBillingCheckoutUrl}
|
||||
>
|
||||
{cta.title}
|
||||
</CheckoutButton>
|
||||
@@ -189,7 +179,7 @@ function FeatureItem({ text }: FeatureItemProps) {
|
||||
const titleStr = Array.isArray(text) ? text[0] : text;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckIcon className="size-4 shrink-0 text-green-500" />
|
||||
{Array.isArray(text) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { useClipboard } from "hooks/useClipboard";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, XIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type ThirdwebClient, isAddress } from "thirdweb";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
@@ -16,6 +16,7 @@ import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { ToolTipLabel } from "../ui/tooltip";
|
||||
import { Img } from "./Img";
|
||||
|
||||
export function WalletAddress(props: {
|
||||
@@ -44,7 +45,16 @@ export function WalletAddress(props: {
|
||||
const { onCopy, hasCopied } = useClipboard(address, 2000);
|
||||
|
||||
if (!isAddress(address)) {
|
||||
return <span>Invalid Address ({address})</span>;
|
||||
return (
|
||||
<ToolTipLabel label={address} hoverable>
|
||||
<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" />
|
||||
</div>
|
||||
Invalid Address
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
// special case for zero address
|
||||
|
||||
@@ -91,13 +91,11 @@ export function DatePickerWithRange(props: {
|
||||
name: "From",
|
||||
onClick: () => setScreen("from"),
|
||||
isActive: screen === "from",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "To",
|
||||
onClick: () => setScreen("to"),
|
||||
isActive: screen === "to",
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as parserBabel from "prettier/plugins/babel";
|
||||
import * as estree from "prettier/plugins/estree";
|
||||
import { format } from "prettier/standalone";
|
||||
import { type BundledLanguage, codeToHtml } from "shiki";
|
||||
|
||||
@@ -21,10 +20,11 @@ export async function getCodeHtml(
|
||||
ignoreFormattingErrors?: boolean;
|
||||
},
|
||||
) {
|
||||
const estreePlugin = await import("prettier/plugins/estree");
|
||||
const formattedCode = isPrettierSupportedLang(lang)
|
||||
? await format(code, {
|
||||
parser: "babel-ts",
|
||||
plugins: [parserBabel, estree],
|
||||
plugins: [parserBabel, estreePlugin.default],
|
||||
printWidth: 60,
|
||||
}).catch((e) => {
|
||||
if (!options?.ignoreFormattingErrors) {
|
||||
|
||||
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:selection:bg-foreground/20",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function TabButtons(props: {
|
||||
name: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
isEnabled?: boolean;
|
||||
isDisabled?: boolean;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
}[];
|
||||
tabClassName?: string;
|
||||
@@ -119,11 +119,11 @@ export function TabButtons(props: {
|
||||
"relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2 font-medium text-sm hover:bg-accent lg:px-3 lg:text-base",
|
||||
!tab.isActive &&
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
!tab.isEnabled && "cursor-not-allowed opacity-50",
|
||||
tab.isDisabled && "cursor-not-allowed opacity-50",
|
||||
props.tabClassName,
|
||||
tab.isActive && props.activeTabClassName,
|
||||
)}
|
||||
onClick={tab.isEnabled ? tab.onClick : undefined}
|
||||
onClick={!tab.isDisabled ? tab.onClick : undefined}
|
||||
>
|
||||
{tab.icon && (
|
||||
<tab.icon className={cn("size-6", props.tabIconClassName)} />
|
||||
|
||||
@@ -44,14 +44,6 @@ export type Account = {
|
||||
// TODO - add image URL
|
||||
};
|
||||
|
||||
interface UpdateAccountInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
linkWallet?: boolean;
|
||||
subscribeToUpdates?: boolean;
|
||||
onboardSkipped?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateAccountNotificationsInput {
|
||||
billing: "email" | "none";
|
||||
updates: "email" | "none";
|
||||
@@ -140,45 +132,40 @@ export function useAccountCredits() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
const address = useActiveAccount()?.address;
|
||||
type UpdateAccountParams = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
linkWallet?: boolean;
|
||||
subscribeToUpdates?: boolean;
|
||||
onboardSkipped?: boolean;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: UpdateAccountInput) => {
|
||||
type Result = {
|
||||
data: object;
|
||||
error?: { message: string };
|
||||
};
|
||||
export async function updateAccountClient(input: UpdateAccountParams) {
|
||||
type Result = {
|
||||
data: object;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/account",
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(address || ""),
|
||||
});
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/account",
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export function useUpdateNotifications() {
|
||||
@@ -221,77 +208,61 @@ export function useUpdateNotifications() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfirmEmail() {
|
||||
return useMutation({
|
||||
mutationFn: async (input: ConfirmEmailInput) => {
|
||||
type Result = {
|
||||
error?: { message: string };
|
||||
data: { team: Team; account: Account };
|
||||
};
|
||||
export const verifyEmailClient = async (input: ConfirmEmailInput) => {
|
||||
type Result = {
|
||||
error?: { message: string };
|
||||
data: { team: Team; account: Account };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/account/confirmEmail",
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/account/confirmEmail",
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResendEmailConfirmation() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
type Result = {
|
||||
error?: { message: string };
|
||||
data: object;
|
||||
};
|
||||
const json = res.data;
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/account/resendEmailConfirmation",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
return json.data;
|
||||
};
|
||||
|
||||
const json = res.data;
|
||||
export const resendEmailClient = async () => {
|
||||
type Result = {
|
||||
error?: { message: string };
|
||||
data: object;
|
||||
};
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(address || ""),
|
||||
});
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/account/resendEmailConfirmation",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
};
|
||||
|
||||
export async function createProjectClient(
|
||||
teamId: string,
|
||||
|
||||
+12
-29
@@ -1,59 +1,42 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { NATIVE_TOKEN_ADDRESS, defineChain, getContract } from "thirdweb";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { name } from "thirdweb/extensions/common";
|
||||
|
||||
type RouteListCardProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri: string | null;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol: string;
|
||||
originTokenName: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri: string | null;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol: string;
|
||||
destinationTokenName: string;
|
||||
};
|
||||
|
||||
export async function RouteListCard({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenName,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenName,
|
||||
}: RouteListCardProps) {
|
||||
const [
|
||||
originChain,
|
||||
originTokenName,
|
||||
destinationChain,
|
||||
destinationTokenName,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: name({
|
||||
contract: getContract({
|
||||
address: originTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(originChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: name({
|
||||
contract: getContract({
|
||||
address: destinationTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(destinationChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: originTokenIconUri,
|
||||
@@ -78,17 +61,17 @@ export async function RouteListCard({
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
className="size-8 rounded-full bg-white"
|
||||
className="size-8 rounded-full border border-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-white/10" />
|
||||
<div className="size-8 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
className="-translate-x-4 size-8 rounded-full bg-white ring-2 ring-card"
|
||||
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
|
||||
|
||||
+10
-28
@@ -2,59 +2,41 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { NATIVE_TOKEN_ADDRESS, getContract } from "thirdweb";
|
||||
import { defineChain, getChainMetadata } from "thirdweb/chains";
|
||||
import { symbol } from "thirdweb/extensions/common";
|
||||
|
||||
type RouteListRowProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri: string | null;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol?: string;
|
||||
originTokenName?: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri: string | null;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol?: string;
|
||||
destinationTokenName?: string;
|
||||
};
|
||||
|
||||
export async function RouteListRow({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenSymbol,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenSymbol,
|
||||
}: RouteListRowProps) {
|
||||
const [
|
||||
originChain,
|
||||
originTokenSymbol,
|
||||
destinationChain,
|
||||
destinationTokenSymbol,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: symbol({
|
||||
contract: getContract({
|
||||
address: originTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(originChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: symbol({
|
||||
contract: getContract({
|
||||
address: destinationTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(destinationChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: originTokenIconUri,
|
||||
@@ -80,7 +62,7 @@ export async function RouteListRow({
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
className="size-6 rounded-full bg-muted-foreground"
|
||||
className="size-6 rounded-full border border-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
@@ -115,7 +97,7 @@ export async function RouteListRow({
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
className="size-6 rounded-full bg-muted-foreground"
|
||||
className="size-6 rounded-full border border-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
|
||||
+9
-1
@@ -50,7 +50,7 @@ async function getRoutesToRender(params: SearchParams) {
|
||||
}
|
||||
}
|
||||
// Temporary, will update this after the /routes endpoint
|
||||
filters.limit = 10_000;
|
||||
filters.limit = 50_000;
|
||||
|
||||
const routes = await getRoutes(filters);
|
||||
|
||||
@@ -107,9 +107,13 @@ export async function RoutesData(props: {
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
originTokenName={route.originToken.name}
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -126,9 +130,13 @@ export async function RoutesData(props: {
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
originTokenName={route.originToken.name}
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function ChainListPage(props: {
|
||||
export default async function RoutesPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
@@ -4,11 +4,15 @@ export type Route = {
|
||||
originToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri: string;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
destinationToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri: string;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
};
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ import {
|
||||
useWalletBalance,
|
||||
} from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
|
||||
import { isAccountOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
@@ -210,7 +210,7 @@ export function FaucetButton({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOnboardingComplete(twAccount)) {
|
||||
if (!isAccountOnboardingComplete(twAccount)) {
|
||||
return (
|
||||
<Button asChild className="w-full">
|
||||
<Link
|
||||
|
||||
-1
@@ -71,7 +71,6 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
name: mode,
|
||||
isActive: mode === listingMode,
|
||||
onClick: () => setListingMode(mode),
|
||||
isEnabled: true,
|
||||
}))}
|
||||
tabClassName="text-sm gap-2 !text-sm"
|
||||
tabContainerClassName="gap-0.5"
|
||||
|
||||
+138
-29
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -11,6 +18,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -33,7 +41,9 @@ import { useForm } from "react-hook-form";
|
||||
import {
|
||||
defineChain,
|
||||
eth_getCode,
|
||||
getContract,
|
||||
getRpcClient,
|
||||
prepareContractCall,
|
||||
prepareTransaction,
|
||||
sendAndConfirmTransaction,
|
||||
} from "thirdweb";
|
||||
@@ -57,19 +67,21 @@ type CrossChain = {
|
||||
status: "DEPLOYED" | "NOT_DEPLOYED";
|
||||
};
|
||||
|
||||
const interopChains = ["420120000", "420120001"];
|
||||
|
||||
type ChainId = "420120000" | "420120001";
|
||||
|
||||
const formSchema = z.object({
|
||||
amounts: z.object({
|
||||
"84532": z.string(),
|
||||
"11155420": z.string(),
|
||||
"919": z.string(),
|
||||
"111557560": z.string(),
|
||||
"999999999": z.string(),
|
||||
"11155111": z.string(),
|
||||
"421614": z.string(),
|
||||
"420120000": z.string(),
|
||||
"420120001": z.string(),
|
||||
}),
|
||||
});
|
||||
type FormSchema = z.output<typeof formSchema>;
|
||||
|
||||
const positiveIntegerRegex = /^[0-9]\d*$/;
|
||||
const superchainBridgeAddress = "0x4200000000000000000000000000000000000028";
|
||||
|
||||
export function DataTable({
|
||||
data,
|
||||
coreMetadata,
|
||||
@@ -97,13 +109,21 @@ export function DataTable({
|
||||
"Failed to deploy contract",
|
||||
);
|
||||
|
||||
const isCrosschain = !!modulesMetadata?.find(
|
||||
(m) => m.name === "SuperChainInterop",
|
||||
);
|
||||
|
||||
const addRowMutation = useMutation({
|
||||
mutationFn: async (chain: { chainId: number; name: string }) => {
|
||||
if (coreContract.chain.id === chain.chainId) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const c = defineChain(chain.chainId);
|
||||
const code = await eth_getCode(
|
||||
getRpcClient({
|
||||
client: getThirdwebClient(),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(chain.chainId),
|
||||
chain: c,
|
||||
}),
|
||||
{ address: coreContract.address },
|
||||
);
|
||||
@@ -155,19 +175,59 @@ export function DataTable({
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
defaultValues: {
|
||||
amounts: {
|
||||
"84532": "", // Base
|
||||
"11155420": "", // OP testnet
|
||||
"919": "", // Mode Network
|
||||
"111557560": "", // Cyber
|
||||
"999999999": "", // Zora
|
||||
"11155111": "", // Sepolia
|
||||
"421614": "",
|
||||
"420120000": "",
|
||||
"420120001": "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const crossChainTransfer = async (chainId: ChainId) => {
|
||||
if (!activeAccount) {
|
||||
throw new Error("Account not connected");
|
||||
}
|
||||
const amount = form.getValues().amounts[chainId];
|
||||
if (!positiveIntegerRegex.test(amount)) {
|
||||
form.setError(`amounts.${chainId}`, { message: "Invalid Amount" });
|
||||
return;
|
||||
}
|
||||
|
||||
const superChainBridge = getContract({
|
||||
address: superchainBridgeAddress,
|
||||
chain: coreContract.chain,
|
||||
client: coreContract.client,
|
||||
});
|
||||
|
||||
const sendErc20Tx = prepareContractCall({
|
||||
contract: superChainBridge,
|
||||
method:
|
||||
"function sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)",
|
||||
params: [
|
||||
coreContract.address,
|
||||
activeAccount.address,
|
||||
BigInt(amount),
|
||||
BigInt(chainId),
|
||||
],
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: activeAccount,
|
||||
transaction: sendErc20Tx,
|
||||
});
|
||||
};
|
||||
|
||||
const crossChainTransferNotifications = useTxNotifications(
|
||||
"Successfully submitted cross chain transfer",
|
||||
"Failed to submit cross chain transfer",
|
||||
);
|
||||
|
||||
const crossChainTransferMutation = useMutation({
|
||||
mutationFn: crossChainTransfer,
|
||||
onSuccess: crossChainTransferNotifications.onSuccess,
|
||||
onError: crossChainTransferNotifications.onError,
|
||||
});
|
||||
|
||||
const columns: ColumnDef<CrossChain>[] = [
|
||||
{
|
||||
accessorKey: "network",
|
||||
@@ -208,6 +268,54 @@ export function DataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "transfer",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const chain = row.getValue("chainId");
|
||||
if (
|
||||
row.getValue("status") === "DEPLOYED" &&
|
||||
interopChains.includes(String(chain)) &&
|
||||
isCrosschain
|
||||
) {
|
||||
return (
|
||||
<FormField
|
||||
disabled={false}
|
||||
control={form.control}
|
||||
name={`amounts.${row.getValue("chainId") as ChainId}`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<ToolTipLabel label="Bridge tokens">
|
||||
<div className="flex">
|
||||
<Input
|
||||
className="w-22 rounded-r-none border-r-0"
|
||||
placeholder="amount"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={false}
|
||||
onClick={() =>
|
||||
crossChainTransferMutation.mutate(
|
||||
row.getValue("chainId"),
|
||||
)
|
||||
}
|
||||
className="rounded-lg rounded-l-none border border-l-0"
|
||||
>
|
||||
Bridge
|
||||
</Button>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -268,6 +376,18 @@ export function DataTable({
|
||||
crosschainContractAddress = coreContract.address;
|
||||
}
|
||||
} else {
|
||||
if (modulesMetadata) {
|
||||
for (const m of modulesMetadata) {
|
||||
await getOrDeployInfraForPublishedContract({
|
||||
chain,
|
||||
client,
|
||||
account: activeAccount,
|
||||
contractId: m.name,
|
||||
publisher: m.publisher,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
crosschainContractAddress = await deployContractfromDeployMetadata({
|
||||
account: activeAccount,
|
||||
chain,
|
||||
@@ -283,17 +403,6 @@ export function DataTable({
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
if (modulesMetadata) {
|
||||
for (const m of modulesMetadata) {
|
||||
await getOrDeployInfraForPublishedContract({
|
||||
chain,
|
||||
client,
|
||||
account: activeAccount,
|
||||
contractId: m.name,
|
||||
publisher: m.publisher,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
deployStatusModal.nextStep();
|
||||
deployStatusModal.setViewContractLink(
|
||||
|
||||
+12
-4
@@ -2,6 +2,7 @@ import { fetchPublishedContractsFromDeploy } from "components/contract-component
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
type ContractOptions,
|
||||
eth_blockNumber,
|
||||
eth_getTransactionByHash,
|
||||
eth_getTransactionReceipt,
|
||||
getContractEvents,
|
||||
@@ -110,10 +111,16 @@ export default async function Page(props: {
|
||||
let creationBlockNumber: bigint | undefined;
|
||||
|
||||
if (twCloneFactoryContract) {
|
||||
const latestBlockNumber = await eth_blockNumber(
|
||||
getRpcClient({
|
||||
client: contract.client,
|
||||
chain: contract.chain,
|
||||
}),
|
||||
);
|
||||
const events = await getContractEvents({
|
||||
contract: twCloneFactoryContract,
|
||||
events: [ProxyDeployedEvent],
|
||||
blockRange: 123456n,
|
||||
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
|
||||
});
|
||||
const event = events.find(
|
||||
(e) =>
|
||||
@@ -165,7 +172,7 @@ export default async function Page(props: {
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((c) => c.status === "DEPLOYED");
|
||||
).filter((c) => c.chainId !== contract.chain.id);
|
||||
|
||||
let coreMetadata: FetchDeployMetadataResult | undefined;
|
||||
try {
|
||||
@@ -260,10 +267,11 @@ export default async function Page(props: {
|
||||
<>
|
||||
<div>
|
||||
<h2 className="mb-1 font-bold text-2xl tracking-tight">
|
||||
Deploy Cross-chain
|
||||
Cross-chain contracts
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Deterministically deploy your contracts on multiple networks.
|
||||
Deterministically deploy and interact with your contracts on multiple
|
||||
networks.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-10" />
|
||||
|
||||
@@ -45,7 +45,7 @@ export const products = [
|
||||
link: "https://portal.thirdweb.com/connect/account-abstraction/overview",
|
||||
},
|
||||
{
|
||||
name: "Pay",
|
||||
name: "Universal Bridge",
|
||||
id: "pay",
|
||||
icon: PayIcon,
|
||||
description: "Point of sale solution for bridging, onramping & swapping",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
@@ -10,6 +10,8 @@ import ancient8Banner from "./temp-assets/ancient8-banner.png";
|
||||
import assetChainBanner from "./temp-assets/assetChainBanner.png";
|
||||
import assetChainCTABG from "./temp-assets/assetChainCTABanner.png";
|
||||
|
||||
import hemiSepoliaBanner from "./temp-assets/HemiBanner.png";
|
||||
import hemiSepoliaCTA from "./temp-assets/HemiCTA.png";
|
||||
import appChainBanner from "./temp-assets/appChainBanner.jpg";
|
||||
// TEMPORARY
|
||||
import appchainCTA from "./temp-assets/appchainCTA.png";
|
||||
@@ -452,6 +454,18 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
},
|
||||
//HemiSepolia
|
||||
743111: {
|
||||
headerImgUrl: hemiSepoliaBanner.src,
|
||||
about:
|
||||
"The Hemi Network is a modular Layer-2 protocol for superior scaling, security, and interoperability, powered by Bitcoin and Ethereum.",
|
||||
cta: {
|
||||
backgroundImageUrl: hemiSepoliaCTA.src,
|
||||
title: "One Network, Powered by Bitcoin and Ethereum",
|
||||
buttonLink: "https://hemi.xyz/",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//Hashfire
|
||||
4227: {
|
||||
headerImgUrl: hashfireBanner.src,
|
||||
|
||||
@@ -100,7 +100,6 @@ const DeployOptions = (props: {
|
||||
key,
|
||||
name: value.title,
|
||||
isActive: activeTab === key,
|
||||
isEnabled: true,
|
||||
onClick: () => setActiveTab(key as TabId),
|
||||
}))}
|
||||
tabClassName="font-medium"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { redirectToBillingPortal } from "@/actions/billing";
|
||||
import { getBillingPortalUrl } from "@/actions/billing";
|
||||
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { updateAccount } from "@/actions/updateAccount";
|
||||
@@ -36,6 +36,16 @@ export function AccountSettingsPage(props: {
|
||||
client={props.client}
|
||||
defaultTeamSlug={props.defaultTeamSlug}
|
||||
defaultTeamName={props.defaultTeamName}
|
||||
cancelSubscriptions={async () => {
|
||||
const res = await apiServerProxy({
|
||||
method: "DELETE",
|
||||
pathname: `/v1/teams/${props.defaultTeamSlug}/subscriptions`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
}}
|
||||
onAccountDeleted={async () => {
|
||||
await doLogout();
|
||||
if (activeWallet) {
|
||||
@@ -58,7 +68,7 @@ export function AccountSettingsPage(props: {
|
||||
return { status: 500 };
|
||||
}
|
||||
}}
|
||||
redirectToBillingPortal={redirectToBillingPortal}
|
||||
getBillingPortalUrl={getBillingPortalUrl}
|
||||
updateAccountAvatar={async (file) => {
|
||||
let uri: string | undefined = undefined;
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ function Variants() {
|
||||
<AccountSettingsPageUI
|
||||
defaultTeamSlug="foo"
|
||||
defaultTeamName="Foo"
|
||||
redirectToBillingPortal={async () => {
|
||||
getBillingPortalUrl={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { status: 200 };
|
||||
}}
|
||||
@@ -143,6 +143,9 @@ function Variants() {
|
||||
onAccountDeleted={() => {
|
||||
console.log("Account deleted");
|
||||
}}
|
||||
cancelSubscriptions={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { BillingBillingPortalAction } from "@/actions/billing";
|
||||
import type { GetBillingPortalUrlAction } from "@/actions/billing";
|
||||
import { BillingPortalButton } from "@/components/billing";
|
||||
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
@@ -38,7 +38,7 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { EllipsisIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { CircleXIcon, EllipsisIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -63,7 +63,8 @@ export function AccountSettingsPageUI(props: {
|
||||
onAccountDeleted: () => void;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
getBillingPortalUrl: GetBillingPortalUrlAction;
|
||||
cancelSubscriptions: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -88,7 +89,8 @@ export function AccountSettingsPageUI(props: {
|
||||
onAccountDeleted={props.onAccountDeleted}
|
||||
defaultTeamSlug={props.defaultTeamSlug}
|
||||
defaultTeamName={props.defaultTeamName}
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
getBillingPortalUrl={props.getBillingPortalUrl}
|
||||
cancelSubscriptions={props.cancelSubscriptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -206,7 +208,8 @@ function DeleteAccountCard(props: {
|
||||
onAccountDeleted: () => void;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
getBillingPortalUrl: GetBillingPortalUrlAction;
|
||||
cancelSubscriptions: () => Promise<void>;
|
||||
}) {
|
||||
const title = "Delete Account";
|
||||
const description = (
|
||||
@@ -230,6 +233,10 @@ function DeleteAccountCard(props: {
|
||||
},
|
||||
});
|
||||
|
||||
const cancelSubscriptions = useMutation({
|
||||
mutationFn: props.cancelSubscriptions,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
deleteAccount.mutate();
|
||||
}
|
||||
@@ -264,25 +271,47 @@ function DeleteAccountCard(props: {
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to delete account</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span className="mb-1 block">
|
||||
Your default team "{props.defaultTeamName}" has active
|
||||
subscriptions. These subscriptions have to be cancelled
|
||||
first before deleting account
|
||||
</span>
|
||||
<span className="mb-4 block">
|
||||
Reach out to support to help you cancel them
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
asChild
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Link href="/support/create-ticket">
|
||||
Contact Support
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="mb-4">
|
||||
<span className="block">
|
||||
Your default team "{props.defaultTeamName}" has active
|
||||
subscriptions. These subscriptions have to be cancelled
|
||||
before deleting account
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
const promise = cancelSubscriptions.mutateAsync();
|
||||
toast.promise(promise, {
|
||||
success: "Subscriptions cancelled successfully",
|
||||
error: "Failed to cancel subscriptions",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cancelSubscriptions.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<CircleXIcon className="size-4" />
|
||||
)}
|
||||
Cancel subscriptions
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Link href="/support/create-ticket" target="_blank">
|
||||
Contact Support
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -299,11 +328,12 @@ function DeleteAccountCard(props: {
|
||||
account
|
||||
</span>
|
||||
<BillingPortalButton
|
||||
size="sm"
|
||||
buttonProps={{
|
||||
size: "sm",
|
||||
variant: "default",
|
||||
}}
|
||||
teamSlug={props.defaultTeamSlug}
|
||||
variant="default"
|
||||
redirectPath="/team"
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
getBillingPortalUrl={props.getBillingPortalUrl}
|
||||
>
|
||||
Manage Billing
|
||||
</BillingPortalButton>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getAuthToken } from "../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { isOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
|
||||
import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
/**
|
||||
* Just get the account object without enforcing onboarding.
|
||||
@@ -22,6 +22,11 @@ export async function getRawAccount() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Error fetching account", res.status, res.statusText);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
@@ -40,7 +45,7 @@ export async function getValidAccount(pagePath?: string) {
|
||||
const account = await getRawAccount();
|
||||
|
||||
// enforce login & onboarding
|
||||
if (!account || !isOnboardingComplete(account)) {
|
||||
if (!account || !isAccountOnboardingComplete(account)) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { notFound } from "next/navigation";
|
||||
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
|
||||
import { InviteTeamMembers } from "../../../../login/onboarding/team-onboarding/team-onboarding";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamOnboardingLayout currentStep={2}>
|
||||
<InviteTeamMembers team={team} />
|
||||
</TeamOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamBySlug, getTeams } from "@/api/team";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getValidAccount } from "../../../account/settings/getAccount";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { TeamHeaderLoggedIn } from "../../../team/components/TeamHeader/team-header-logged-in.client";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [team, account, accountAddress, authToken, teams] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getValidAccount(`/team/${params.team_slug}`),
|
||||
getAuthTokenWalletAddress(),
|
||||
getAuthToken(),
|
||||
getTeams(),
|
||||
]);
|
||||
|
||||
if (!accountAddress || !account || !teams || !authToken) {
|
||||
loginRedirect(`/get-started/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Note:
|
||||
// Do not check that team is already onboarded or not and redirect away from /get-started pages
|
||||
// because the team is marked as onboarded in the first step- instead of after completing all the steps
|
||||
|
||||
const teamsAndProjects = await Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
team,
|
||||
projects: await getProjects(team.slug),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh grow flex-col">
|
||||
<div className="border-b bg-card">
|
||||
<TeamHeaderLoggedIn
|
||||
account={account}
|
||||
accountAddress={accountAddress}
|
||||
currentProject={undefined}
|
||||
currentTeam={team}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
/>
|
||||
</div>
|
||||
{props.children}
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { TeamOnboardingLayout } from "../../../login/onboarding/onboarding-layout";
|
||||
import { TeamInfoForm } from "../../../login/onboarding/team-onboarding/team-onboarding";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [team, authToken] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/get-started/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamOnboardingLayout currentStep={1}>
|
||||
<TeamInfoForm
|
||||
client={getThirdwebClient(authToken)}
|
||||
teamId={team.id}
|
||||
teamSlug={params.team_slug}
|
||||
/>
|
||||
</TeamOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { redirectToCheckout } from "@/actions/billing";
|
||||
import { getRawAccountAction } from "@/actions/getAccount";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { ToggleThemeButton } from "@/components/color-mode-toggle";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { TURNSTILE_SITE_KEY } from "@/constants/env";
|
||||
@@ -12,16 +12,21 @@ import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import {
|
||||
ConnectEmbed,
|
||||
useActiveAccount,
|
||||
useActiveWalletConnectionStatus,
|
||||
} from "thirdweb/react";
|
||||
import { createWallet, inAppWallet } from "thirdweb/wallets";
|
||||
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
|
||||
import { isVercel } from "../../lib/vercel-utils";
|
||||
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
|
||||
import { getSDKTheme } from "../components/sdk-component-theme";
|
||||
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
|
||||
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
|
||||
import { isAccountOnboardingComplete } from "./onboarding/isOnboardingRequired";
|
||||
|
||||
const LazyOnboardingUI = lazy(
|
||||
() => import("./onboarding/on-boarding-ui.client"),
|
||||
const LazyAccountOnboarding = lazy(
|
||||
() => import("./onboarding/account-onboarding"),
|
||||
);
|
||||
|
||||
const loginOptions = [
|
||||
@@ -115,28 +120,13 @@ export function LoginAndOnboardingPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginAndOnboardingPageContent(props: {
|
||||
account: Account | undefined;
|
||||
redirectPath: string;
|
||||
loginWithInAppWallet: boolean;
|
||||
function LoginPageContainer(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex grow flex-col">
|
||||
<main className="container z-10 flex grow flex-col justify-center gap-6 py-12">
|
||||
<ClientOnly
|
||||
ssr={
|
||||
<div className="flex justify-center">
|
||||
<LoadingCard />
|
||||
</div>
|
||||
}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<PageContent
|
||||
redirectPath={props.redirectPath}
|
||||
account={props.account}
|
||||
loginWithInAppWallet={props.loginWithInAppWallet}
|
||||
/>
|
||||
</ClientOnly>
|
||||
<>
|
||||
<main className="container z-10 flex grow flex-col items-center justify-center gap-6 py-12">
|
||||
{props.children}
|
||||
</main>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -145,23 +135,16 @@ export function LoginAndOnboardingPageContent(props: {
|
||||
src="/assets/login/background.svg"
|
||||
className="-bottom-12 -right-12 pointer-events-none fixed lg:right-0 lg:bottom-0"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCard() {
|
||||
return (
|
||||
<div className="flex min-h-[522px] w-full items-center justify-center rounded-xl border border-border bg-card shadow-lg max-sm:max-w-[358px] lg:min-h-[568px] lg:w-[728px]">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageContent(props: {
|
||||
export function LoginAndOnboardingPageContent(props: {
|
||||
redirectPath: string;
|
||||
account: Account | undefined;
|
||||
loginWithInAppWallet: boolean;
|
||||
}) {
|
||||
const accountAddress = useActiveAccount()?.address;
|
||||
const [screen, setScreen] = useState<
|
||||
| { id: "login" }
|
||||
| {
|
||||
@@ -189,7 +172,7 @@ function PageContent(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOnboardingComplete(account)) {
|
||||
if (!isAccountOnboardingComplete(account)) {
|
||||
setScreen({
|
||||
id: "onboarding",
|
||||
account,
|
||||
@@ -207,37 +190,55 @@ function PageContent(props: {
|
||||
}
|
||||
}, [connectionStatus, screen.id]);
|
||||
|
||||
if (connectionStatus === "connecting") {
|
||||
return <LoadingCard />;
|
||||
if (screen.id === "complete") {
|
||||
return <GenericLoadingPage className="border-none" />;
|
||||
}
|
||||
|
||||
if (connectionStatus !== "connected" || screen.id === "login") {
|
||||
if (connectionStatus === "connecting") {
|
||||
return (
|
||||
<CustomConnectEmbed
|
||||
onLogin={onLogin}
|
||||
loginWithInAppWallet={props.loginWithInAppWallet}
|
||||
/>
|
||||
<LoginPageContainer>
|
||||
<ConnectEmbedSizedLoadingCard />
|
||||
</LoginPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
connectionStatus !== "connected" ||
|
||||
screen.id === "login" ||
|
||||
!accountAddress
|
||||
) {
|
||||
return (
|
||||
<LoginPageContainer>
|
||||
<CustomConnectEmbed
|
||||
onLogin={onLogin}
|
||||
loginWithInAppWallet={props.loginWithInAppWallet}
|
||||
/>
|
||||
</LoginPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.id === "onboarding") {
|
||||
return (
|
||||
<Suspense fallback={<LoadingCard />}>
|
||||
<LazyOnboardingUI
|
||||
account={screen.account}
|
||||
<Suspense fallback={<GenericLoadingPage className="border-none" />}>
|
||||
<LazyAccountOnboarding
|
||||
onComplete={onComplete}
|
||||
redirectPath={props.redirectPath}
|
||||
redirectToCheckout={redirectToCheckout}
|
||||
onLogout={() => {
|
||||
setScreen({ id: "login" });
|
||||
}}
|
||||
skipShowingPlans={props.redirectPath.startsWith("/join/team")}
|
||||
accountAddress={accountAddress}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <LoadingCard />;
|
||||
return (
|
||||
<LoginPageContainer>
|
||||
<CustomConnectEmbed
|
||||
onLogin={onLogin}
|
||||
loginWithInAppWallet={props.loginWithInAppWallet}
|
||||
/>
|
||||
</LoginPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomConnectEmbed(props: {
|
||||
@@ -246,55 +247,87 @@ function CustomConnectEmbed(props: {
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const client = useThirdwebClient();
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [alwaysShowTurnstile, setAlwaysShowTurnstile] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Turnstile
|
||||
options={{
|
||||
// only show if interaction is required
|
||||
appearance: "interaction-only",
|
||||
appearance: alwaysShowTurnstile ? "always" : "interaction-only",
|
||||
// match the theme of the rest of the app
|
||||
theme: theme === "light" ? "light" : "dark",
|
||||
}}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
<ConnectEmbed
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
doLogin: async (params) => {
|
||||
try {
|
||||
const result = await doLogin(params, turnstileToken);
|
||||
if (result.error) {
|
||||
console.error("Failed to login", result.error, result.context);
|
||||
throw new Error(result.error);
|
||||
<ClientOnly ssr={<ConnectEmbedSizedLoadingCard />}>
|
||||
<ConnectEmbed
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
doLogin: async (params) => {
|
||||
if (isVercel() && !turnstileToken) {
|
||||
setAlwaysShowTurnstile(true);
|
||||
throw new Error("Please complete the captcha.");
|
||||
}
|
||||
props.onLogin();
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
doLogout,
|
||||
isLoggedIn: async (x) => {
|
||||
const isLoggedInResult = await isLoggedIn(x);
|
||||
if (isLoggedInResult) {
|
||||
props.onLogin();
|
||||
}
|
||||
return isLoggedInResult;
|
||||
},
|
||||
}}
|
||||
wallets={
|
||||
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
|
||||
}
|
||||
client={client}
|
||||
modalSize="wide"
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
className="shadow-lg"
|
||||
privacyPolicyUrl="/privacy-policy"
|
||||
termsOfServiceUrl="/terms"
|
||||
/>
|
||||
|
||||
try {
|
||||
const result = await doLogin(params, turnstileToken);
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"Failed to login",
|
||||
result.error,
|
||||
result.context,
|
||||
);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
props.onLogin();
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
doLogout,
|
||||
isLoggedIn: async (x) => {
|
||||
const isLoggedInResult = await isLoggedIn(x);
|
||||
if (isLoggedInResult) {
|
||||
props.onLogin();
|
||||
}
|
||||
return isLoggedInResult;
|
||||
},
|
||||
}}
|
||||
wallets={
|
||||
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
|
||||
}
|
||||
client={client}
|
||||
modalSize="wide"
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
className="shadow-lg"
|
||||
privacyPolicyUrl="/privacy-policy"
|
||||
termsOfServiceUrl="/terms"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectEmbedSizedCard(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[522px] w-full items-center justify-center rounded-xl border border-border bg-card shadow-lg max-sm:max-w-[358px] lg:min-h-[568px] lg:w-[728px]">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectEmbedSizedLoadingCard() {
|
||||
return (
|
||||
<ConnectEmbedSizedCard>
|
||||
<Spinner className="size-10" />
|
||||
</ConnectEmbedSizedCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function getLoginPayload(
|
||||
|
||||
export async function doLogin(
|
||||
payload: VerifyLoginPayloadParams,
|
||||
turnstileToken: string,
|
||||
turnstileToken: string | undefined,
|
||||
) {
|
||||
if (!THIRDWEB_API_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type Account, useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AccountValidationSchema,
|
||||
accountValidationSchema,
|
||||
} from "./validations";
|
||||
|
||||
interface AccountFormProps {
|
||||
account: Account;
|
||||
horizontal?: boolean;
|
||||
showSubscription?: boolean;
|
||||
hideName?: boolean;
|
||||
buttonText?: string;
|
||||
padded?: boolean;
|
||||
trackingCategory?: string;
|
||||
disableUnchanged?: boolean;
|
||||
onSave?: (email: string) => void;
|
||||
onDuplicateError?: (email: string) => void;
|
||||
}
|
||||
|
||||
export const AccountForm: React.FC<AccountFormProps> = ({
|
||||
account,
|
||||
onSave,
|
||||
onDuplicateError,
|
||||
buttonText = "Save",
|
||||
hideName = false,
|
||||
showSubscription = false,
|
||||
disableUnchanged = false,
|
||||
}) => {
|
||||
const [isSubscribing, setIsSubscribing] = useState(true);
|
||||
const trackEvent = useTrack();
|
||||
const form = useForm<AccountValidationSchema>({
|
||||
resolver: zodResolver(accountValidationSchema),
|
||||
defaultValues: {
|
||||
name: account.name || "",
|
||||
email: account.unconfirmedEmail || account.email || "",
|
||||
},
|
||||
values: {
|
||||
name: account.name || "",
|
||||
email: account.unconfirmedEmail || account.email || "",
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useUpdateAccount();
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
const formData = {
|
||||
...values,
|
||||
...(showSubscription
|
||||
? {
|
||||
subscribeToUpdates: isSubscribing,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "update",
|
||||
label: "attempt",
|
||||
data: formData,
|
||||
});
|
||||
|
||||
updateMutation.mutate(formData, {
|
||||
onSuccess: (data) => {
|
||||
if (onSave) {
|
||||
onSave(values.email);
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "update",
|
||||
label: "success",
|
||||
data,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
|
||||
if (
|
||||
onDuplicateError &&
|
||||
error?.message.match(/email address already exists/)
|
||||
) {
|
||||
onDuplicateError(values.email);
|
||||
return;
|
||||
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
|
||||
toast.error("Invalid Email Address");
|
||||
} else {
|
||||
toast.error(error.message || "Failed to update account");
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "update",
|
||||
label: "error",
|
||||
error,
|
||||
fromOnboarding: !!onDuplicateError,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col items-start gap-8 rounded-xl">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
htmlFor="email"
|
||||
errorMessage={
|
||||
form.getFieldState("email", form.formState).error?.message
|
||||
}
|
||||
label="Email"
|
||||
>
|
||||
<Input
|
||||
placeholder="[email protected]"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
id="email"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
{!hideName && (
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
form.getFieldState("name", form.formState).error?.message
|
||||
}
|
||||
label="Name"
|
||||
htmlFor="name"
|
||||
isRequired={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="Company Inc."
|
||||
type="text"
|
||||
{...form.register("name")}
|
||||
id="name"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
|
||||
{showSubscription && (
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={isSubscribing}
|
||||
onCheckedChange={(v) => setIsSubscribing(!!v)}
|
||||
/>
|
||||
Subscribe to new features and key product updates
|
||||
</CheckboxWithLabel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
(disableUnchanged && !form.formState.isDirty)
|
||||
}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{updateMutation.isPending && <Spinner className="size-4" />}
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { RedirectBillingCheckoutAction } from "@/actions/billing";
|
||||
import { TextDivider } from "@/components/TextDivider";
|
||||
import { PricingCard } from "@/components/blocks/pricing-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
|
||||
export function OnboardingChoosePlan(props: {
|
||||
skipPlan: () => Promise<void>;
|
||||
canTrialGrowth: boolean;
|
||||
teamSlug: string;
|
||||
redirectPath: string;
|
||||
redirectToCheckout: RedirectBillingCheckoutAction;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<TitleAndDescription
|
||||
heading="Choose your Plan"
|
||||
description="Get started for free with our Starter plan or subscribe to Growth plan to unlock higher rate limits and advanced features"
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-5">
|
||||
<PricingCard
|
||||
billingPlan="starter"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started for free",
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "starterPlan",
|
||||
},
|
||||
}}
|
||||
redirectPath={props.redirectPath}
|
||||
redirectToCheckout={props.redirectToCheckout}
|
||||
/>
|
||||
|
||||
<PricingCard
|
||||
billingPlan="growth"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started with Growth",
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "growthPlan",
|
||||
},
|
||||
variant: "default",
|
||||
}}
|
||||
canTrialGrowth={false}
|
||||
highlighted
|
||||
redirectPath={props.redirectPath}
|
||||
redirectToCheckout={props.redirectToCheckout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextDivider text="OR" className="my-4" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.skipPlan}
|
||||
className="relative h-auto w-full items-center gap-2 rounded-xl bg-card py-2.5"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-base text-foreground">Skip for now</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
You will have limited access to services
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type Account,
|
||||
useConfirmEmail,
|
||||
useResendEmailConfirmation,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
import {
|
||||
type EmailConfirmationValidationSchema,
|
||||
emailConfirmationValidationSchema,
|
||||
} from "./validations";
|
||||
|
||||
interface OnboardingConfirmEmailProps {
|
||||
email: string;
|
||||
linking?: boolean;
|
||||
onEmailConfirm: (params: {
|
||||
team: Team;
|
||||
account: Account;
|
||||
}) => void;
|
||||
onComplete: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
// TODO - separate out "linking" and "confirmLinking" states into separate components
|
||||
|
||||
export const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
email,
|
||||
linking,
|
||||
onEmailConfirm,
|
||||
onBack,
|
||||
onComplete,
|
||||
}) => {
|
||||
const [token, setToken] = useState("");
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const trackEvent = useTrack();
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
const { onSuccess: onResendSuccess, onError: onResendError } =
|
||||
useTxNotifications(
|
||||
!linking
|
||||
? "We've sent you an email confirmation code."
|
||||
: "We've sent you a wallet linking confirmation code.",
|
||||
!linking
|
||||
? "Couldn't send an email confirmation code. Try later!"
|
||||
: "Couldn't send a wallet linking confirmation code. Try later!",
|
||||
);
|
||||
|
||||
const form = useForm<EmailConfirmationValidationSchema>({
|
||||
resolver: zodResolver(emailConfirmationValidationSchema),
|
||||
defaultValues: {
|
||||
confirmationToken: "",
|
||||
},
|
||||
});
|
||||
|
||||
const confirmEmail = useConfirmEmail();
|
||||
const resendMutation = useResendEmailConfirmation();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setToken(value.toUpperCase());
|
||||
form.setValue("confirmationToken", value);
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
const trackingAction = !linking ? "confirmEmail" : "confirmLinkWallet";
|
||||
|
||||
setSaving(true);
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: trackingAction,
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
confirmEmail.mutate(values, {
|
||||
onSuccess: (response) => {
|
||||
if (!linking) {
|
||||
onEmailConfirm(response);
|
||||
} else {
|
||||
setCompleted(true);
|
||||
}
|
||||
setSaving(false);
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: trackingAction,
|
||||
label: "success",
|
||||
});
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
onError: (error: any) => {
|
||||
const message =
|
||||
"message" in error
|
||||
? error.message
|
||||
: "Couldn't verify your email address. Try later!";
|
||||
|
||||
toast.error(message);
|
||||
form.reset();
|
||||
setToken("");
|
||||
setSaving(false);
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: trackingAction,
|
||||
label: "error",
|
||||
error: message,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const handleResend = () => {
|
||||
setSaving(true);
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "resendEmailConfirmation",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
resendMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setSaving(false);
|
||||
onResendSuccess();
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "resendEmailConfirmation",
|
||||
label: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
onResendError(error);
|
||||
form.reset();
|
||||
setToken("");
|
||||
setSaving(false);
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "resendEmailConfirmation",
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleAndDescription
|
||||
heading={
|
||||
!linking
|
||||
? "Verify your email"
|
||||
: completed
|
||||
? "Wallet linked"
|
||||
: "Linking Wallets"
|
||||
}
|
||||
description={
|
||||
!completed ? (
|
||||
<>
|
||||
Enter the 6 letter confirmation code sent to{" "}
|
||||
<span className="text-medium">{email}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
We've linked{" "}
|
||||
{address && (
|
||||
<span className="font-mono">{shortenString(address)}</span>
|
||||
)}
|
||||
wallet to <span className="font-medium">{email}</span> thirdweb
|
||||
account.
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
{completed && (
|
||||
<Button onClick={onComplete} className="w-full">
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!completed && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
inputMode="text"
|
||||
value={token}
|
||||
onChange={handleChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<InputOTPGroup className="w-full">
|
||||
{new Array(6).fill(0).map((_, idx) => (
|
||||
<InputOTPSlot
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={idx}
|
||||
index={idx}
|
||||
className={cn("h-12 grow text-lg", {
|
||||
"border-red-500": form.getFieldState(
|
||||
"confirmationToken",
|
||||
form.formState,
|
||||
).error,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="gap-2"
|
||||
>
|
||||
{saving && <Spinner className="size-4" />}
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<Button onClick={onBack} disabled={saving} variant="outline">
|
||||
{!linking ? "Update email" : "Use another email"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleResend}
|
||||
disabled={saving}
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Resend Verification Code
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { doLogout } from "../auth-actions";
|
||||
import { AccountForm } from "./AccountForm";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
|
||||
type OnboardingGeneralProps = {
|
||||
account: Account;
|
||||
onSave: (email: string) => void;
|
||||
onDuplicate: (email: string) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
|
||||
account,
|
||||
onSave,
|
||||
onDuplicate,
|
||||
onLogout,
|
||||
}) => {
|
||||
const [existing, setExisting] = useState(false);
|
||||
const activeWallet = useActiveWallet();
|
||||
const { disconnect } = useDisconnect();
|
||||
|
||||
async function handleLogout() {
|
||||
await doLogout();
|
||||
onLogout();
|
||||
if (activeWallet) {
|
||||
disconnect(activeWallet);
|
||||
}
|
||||
}
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: handleLogout,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TitleAndDescription
|
||||
heading={
|
||||
!existing
|
||||
? "Create your thirdweb account"
|
||||
: "Log in to your thirdweb account"
|
||||
}
|
||||
description="Start building web3 apps and games, faster."
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<AccountForm
|
||||
showSubscription={!existing}
|
||||
hideName={existing}
|
||||
account={account}
|
||||
buttonText={!existing ? "Get Started for Free" : "Login"}
|
||||
trackingCategory="onboarding"
|
||||
onSave={onSave}
|
||||
onDuplicateError={onDuplicate}
|
||||
/>
|
||||
|
||||
{!existing ? (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setExisting(true)}>
|
||||
I have a thirdweb account
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
logoutMutation.mutate();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
{logoutMutation.isPending && <Spinner className="size-4" />}
|
||||
Log out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setExisting(false)}>
|
||||
I don't have an account
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
import { TitleAndDescription } from "./Title";
|
||||
|
||||
interface OnboardingLinkWalletProps {
|
||||
email: string;
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
|
||||
email,
|
||||
onSave,
|
||||
onBack,
|
||||
}) => {
|
||||
const address = useActiveAccount()?.address;
|
||||
const trackEvent = useTrack();
|
||||
const updateMutation = useUpdateAccount();
|
||||
|
||||
const handleSubmit = () => {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "linkWallet",
|
||||
label: "attempt",
|
||||
data: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
updateMutation.mutate(
|
||||
{
|
||||
email,
|
||||
linkWallet: true,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "linkWallet",
|
||||
label: "success",
|
||||
data,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "linkWallet",
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleAndDescription
|
||||
heading="Linking Wallets"
|
||||
description={
|
||||
<>
|
||||
We've noticed that there is another account associated with{" "}
|
||||
<span className="text-foreground">{email}</span>. Would you like to
|
||||
link your wallet{" "}
|
||||
{address && (
|
||||
<span className="font-mono text-foreground">
|
||||
{shortenString(address)}
|
||||
</span>
|
||||
)}{" "}
|
||||
to the existing account?
|
||||
<div className="h-2" />
|
||||
Once you agree, we will email you the details.{" "}
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/account/billing/account-info"
|
||||
category="account"
|
||||
label="learn-wallet-linking"
|
||||
target="_blank"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Learn more about wallet linking
|
||||
</TrackedLinkTW>
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<form>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={updateMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{updateMutation.isPending && <Spinner className="size-4" />}
|
||||
{updateMutation.isPending ? "Linking" : "Yes, link them"}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Use another email
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { storybookLog } from "../../../../stories/utils";
|
||||
import { AccountOnboardingLayout } from "../onboarding-layout";
|
||||
import { LinkWalletPrompt } from "./LinkWalletPrompt";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/AccountOnboarding/LinkWalletPrompt",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SendSuccess: Story = {
|
||||
args: {
|
||||
type: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const SendError: Story = {
|
||||
args: {
|
||||
type: "error",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
type: "success" | "error";
|
||||
}) {
|
||||
return (
|
||||
<AccountOnboardingLayout
|
||||
currentStep={1}
|
||||
logout={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
storybookLog("logout");
|
||||
}}
|
||||
>
|
||||
<LinkWalletPrompt
|
||||
onLinkWalletRequestSent={() => {
|
||||
storybookLog("onLinkWalletRequestSent");
|
||||
}}
|
||||
email="[email protected]"
|
||||
requestLinkWallet={async (email) => {
|
||||
storybookLog("requestLinkWallet", email);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (props.type === "error") {
|
||||
throw new Error("Example error");
|
||||
}
|
||||
}}
|
||||
onBack={() => {
|
||||
storybookLog("onBack");
|
||||
}}
|
||||
trackEvent={(params) => {
|
||||
storybookLog("trackEvent", params);
|
||||
}}
|
||||
accountAddress="0x1234567890123456789012345678901234567890"
|
||||
/>
|
||||
</AccountOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { shortenString } from "utils/usedapp-external";
|
||||
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
|
||||
|
||||
export function LinkWalletPrompt(props: {
|
||||
email: string;
|
||||
accountAddress: string;
|
||||
onBack: () => void;
|
||||
requestLinkWallet: (email: string) => Promise<void>;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
onLinkWalletRequestSent: () => void;
|
||||
}) {
|
||||
const requestLinkWallet = useMutation({
|
||||
mutationFn: props.requestLinkWallet,
|
||||
});
|
||||
|
||||
function handleLinkWalletRequest() {
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: "linkWallet",
|
||||
label: "attempt",
|
||||
data: {
|
||||
email: props.email,
|
||||
},
|
||||
});
|
||||
|
||||
requestLinkWallet.mutate(props.email, {
|
||||
onSuccess: (data) => {
|
||||
props.onLinkWalletRequestSent();
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: "linkWallet",
|
||||
label: "success",
|
||||
data,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
console.error(error);
|
||||
toast.error("Failed to send link wallet request");
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: "linkWallet",
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<h3 className="border-b px-4 py-6 font-semibold text-xl tracking-tight lg:p-6">
|
||||
Link your wallet
|
||||
</h3>
|
||||
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="mb-6 space-y-2 text-muted-foreground">
|
||||
<p>
|
||||
An account with{" "}
|
||||
<span className="text-foreground">{props.email}</span> already
|
||||
exists, but your wallet is not linked to that account.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can link your wallet with this account to access it. <br />{" "}
|
||||
Multiple wallets can be linked to the same account.{" "}
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/account/billing/account-info"
|
||||
category="account"
|
||||
label="learn-wallet-linking"
|
||||
target="_blank"
|
||||
className="underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:text-foreground hover:decoration-foreground hover:decoration-solid"
|
||||
>
|
||||
Learn more about wallet linking
|
||||
</TrackedLinkTW>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground">
|
||||
Would you like to link your wallet{" "}
|
||||
<span className="font-mono text-sm tracking-tight">
|
||||
({shortenString(props.accountAddress)})
|
||||
</span>{" "}
|
||||
with this account?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-4 border-t px-4 py-6 md:flex-row lg:justify-between lg:p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-card"
|
||||
onClick={props.onBack}
|
||||
disabled={requestLinkWallet.isPending}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Change Email
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleLinkWalletRequest}
|
||||
disabled={requestLinkWallet.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
Link wallet and continue
|
||||
{requestLinkWallet.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ArrowRightIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { storybookLog } from "../../../../stories/utils";
|
||||
import { AccountOnboardingLayout } from "../onboarding-layout";
|
||||
import { LoginOrSignup } from "./LoginOrSignup";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/AccountOnboarding/LoginOrSignup",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
type: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmailExists: Story = {
|
||||
args: {
|
||||
type: "email-exists",
|
||||
},
|
||||
};
|
||||
|
||||
export const OtherError: Story = {
|
||||
args: {
|
||||
type: "error",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
type: "success" | "error" | "email-exists";
|
||||
}) {
|
||||
return (
|
||||
<AccountOnboardingLayout
|
||||
currentStep={1}
|
||||
logout={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
storybookLog("logout");
|
||||
}}
|
||||
>
|
||||
<LoginOrSignup
|
||||
onRequestSent={(params) => {
|
||||
storybookLog("onRequestSent", params);
|
||||
}}
|
||||
loginOrSignup={async (data) => {
|
||||
storybookLog("loginOrSignup", data);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (props.type === "error") {
|
||||
throw new Error("Error Example");
|
||||
}
|
||||
|
||||
if (props.type === "email-exists") {
|
||||
throw new Error("email address already exists");
|
||||
}
|
||||
}}
|
||||
trackEvent={(params) => {
|
||||
storybookLog("trackEvent", params);
|
||||
}}
|
||||
/>
|
||||
</AccountOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
|
||||
import {
|
||||
type AccountValidationSchema,
|
||||
accountValidationSchema,
|
||||
emailSchema,
|
||||
} from "../validations";
|
||||
|
||||
export function LoginOrSignup(props: {
|
||||
onRequestSent: (options: {
|
||||
email: string;
|
||||
isExistingEmail: boolean;
|
||||
}) => void;
|
||||
loginOrSignup: (input: {
|
||||
email: string;
|
||||
subscribeToUpdates?: true;
|
||||
name?: string;
|
||||
}) => Promise<void>;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"signup" | "login">("signup");
|
||||
const loginOrSignup = useMutation({
|
||||
mutationFn: props.loginOrSignup,
|
||||
});
|
||||
|
||||
function handleSubmit(values: {
|
||||
email: string;
|
||||
subscribeToUpdates?: true;
|
||||
name?: string;
|
||||
}) {
|
||||
loginOrSignup.mutate(values, {
|
||||
onSuccess: (data) => {
|
||||
props.onRequestSent({
|
||||
email: values.email,
|
||||
isExistingEmail: false,
|
||||
});
|
||||
props.trackEvent({
|
||||
category: "onboarding",
|
||||
action: "update",
|
||||
label: "success",
|
||||
data,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error?.message.match(/email address already exists/)) {
|
||||
props.onRequestSent({
|
||||
email: values.email,
|
||||
isExistingEmail: true,
|
||||
});
|
||||
return;
|
||||
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
|
||||
toast.error("Invalid Email Address");
|
||||
} else {
|
||||
toast.error("Failed to send confirmation email");
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: "update",
|
||||
label: "error",
|
||||
error: error.message,
|
||||
fromOnboarding: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border bg-card">
|
||||
<TabButtons
|
||||
tabContainerClassName="px-4 lg:px-6 pt-3 pb-0.5"
|
||||
tabs={[
|
||||
{
|
||||
name: "Create account",
|
||||
onClick: () => setTab("signup"),
|
||||
isActive: tab === "signup",
|
||||
},
|
||||
{
|
||||
name: "I already have an account",
|
||||
onClick: () => setTab("login"),
|
||||
isActive: tab === "login",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === "signup" && (
|
||||
<SignupForm
|
||||
key="signup"
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loginOrSignup.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "login" && (
|
||||
<LoginForm
|
||||
key="login"
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loginOrSignup.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignupForm(props: {
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
email: string;
|
||||
subscribeToUpdates?: true;
|
||||
}) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const [subscribeToUpdates, setSubscribeToUpdates] = useState(true);
|
||||
const form = useForm<AccountValidationSchema>({
|
||||
resolver: zodResolver(accountValidationSchema),
|
||||
values: {
|
||||
name: "",
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
props.onSubmit({
|
||||
...values,
|
||||
...(subscribeToUpdates
|
||||
? {
|
||||
subscribeToUpdates: subscribeToUpdates,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex w-full grow flex-col">
|
||||
<div className="flex w-full flex-col gap-4 px-4 py-6 lg:p-6">
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
form.getFieldState("name", form.formState).error?.message
|
||||
}
|
||||
label="Name"
|
||||
htmlFor="name"
|
||||
isRequired={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="Company Inc."
|
||||
className="bg-background"
|
||||
type="text"
|
||||
{...form.register("name")}
|
||||
id="name"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
htmlFor="email"
|
||||
errorMessage={
|
||||
form.getFieldState("email", form.formState).error?.message
|
||||
}
|
||||
label="Email"
|
||||
>
|
||||
<Input
|
||||
placeholder="[email protected]"
|
||||
className="bg-background"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
id="email"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={subscribeToUpdates}
|
||||
onCheckedChange={(v) => setSubscribeToUpdates(!!v)}
|
||||
/>
|
||||
Subscribe to new features and key product updates
|
||||
</CheckboxWithLabel>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end border-t px-4 py-6 lg:p-6">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={props.isSubmitting}
|
||||
className="gap-2 px-6"
|
||||
>
|
||||
Get Started
|
||||
{props.isSubmitting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ArrowRightIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const loginFormSchema = z.object({
|
||||
email: emailSchema,
|
||||
});
|
||||
|
||||
type LoginFormSchema = z.infer<typeof loginFormSchema>;
|
||||
|
||||
function LoginForm(props: {
|
||||
onSubmit: (values: {
|
||||
email: string;
|
||||
}) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const form = useForm<LoginFormSchema>({
|
||||
resolver: zodResolver(loginFormSchema),
|
||||
values: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
props.onSubmit(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex w-full grow flex-col">
|
||||
<div className="flex w-full flex-col gap-4 px-4 py-6 lg:p-6">
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
htmlFor="email"
|
||||
errorMessage={
|
||||
form.getFieldState("email", form.formState).error?.message
|
||||
}
|
||||
label="Email"
|
||||
>
|
||||
<Input
|
||||
placeholder="[email protected]"
|
||||
type="email"
|
||||
className="bg-background"
|
||||
{...form.register("email")}
|
||||
id="email"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end border-t px-4 py-6 lg:p-6">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={props.isSubmitting}
|
||||
className="gap-2 px-6"
|
||||
>
|
||||
Login
|
||||
{props.isSubmitting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ArrowRightIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
type TitleAndDescriptionProps = {
|
||||
heading: string | JSX.Element;
|
||||
description: string | JSX.Element;
|
||||
};
|
||||
|
||||
export const TitleAndDescription: React.FC<TitleAndDescriptionProps> = ({
|
||||
heading,
|
||||
description,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<h3 className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{heading}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<div className="text-muted-foreground">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { newAccountStub, teamStub } from "../../../../stories/stubs";
|
||||
import { storybookLog } from "../../../../stories/utils";
|
||||
import { AccountOnboardingLayout } from "../onboarding-layout";
|
||||
import { VerifyEmail } from "./VerifyEmail";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/AccountOnboarding/VerifyEmail",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const VerifyEmailSuccess: Story = {
|
||||
args: {
|
||||
verifyEmailType: "success",
|
||||
resendConfirmationEmailType: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const VerifyEmailError: Story = {
|
||||
args: {
|
||||
verifyEmailType: "error",
|
||||
resendConfirmationEmailType: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const ResendCodeError: Story = {
|
||||
args: {
|
||||
verifyEmailType: "success",
|
||||
resendConfirmationEmailType: "error",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
verifyEmailType: "success" | "error";
|
||||
resendConfirmationEmailType: "success" | "error";
|
||||
}) {
|
||||
return (
|
||||
<AccountOnboardingLayout
|
||||
currentStep={2}
|
||||
logout={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
storybookLog("logout");
|
||||
}}
|
||||
>
|
||||
<VerifyEmail
|
||||
title="Custom Title"
|
||||
trackingAction="customAction"
|
||||
accountAddress="0x1234567890123456789012345678901234567890"
|
||||
verifyEmail={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (props.verifyEmailType === "error") {
|
||||
throw new Error("Example error");
|
||||
}
|
||||
return {
|
||||
team: teamStub("foo", "free"),
|
||||
account: newAccountStub(),
|
||||
};
|
||||
}}
|
||||
resendConfirmationEmail={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (props.resendConfirmationEmailType === "error") {
|
||||
throw new Error("Example error");
|
||||
}
|
||||
}}
|
||||
email="[email protected]"
|
||||
onEmailConfirmed={(params) => {
|
||||
storybookLog("onEmailConfirmed", params);
|
||||
}}
|
||||
onBack={() => {
|
||||
storybookLog("onBack");
|
||||
}}
|
||||
trackEvent={(params) => {
|
||||
storybookLog("trackEvent", params);
|
||||
}}
|
||||
/>
|
||||
</AccountOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { TrackingParams } from "hooks/analytics/useTrack";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type EmailConfirmationValidationSchema,
|
||||
emailConfirmationValidationSchema,
|
||||
} from "../validations";
|
||||
|
||||
type VerifyEmailProps = {
|
||||
email: string;
|
||||
onEmailConfirmed: (params: {
|
||||
team: Team;
|
||||
account: Account;
|
||||
}) => void;
|
||||
onBack: () => void;
|
||||
verifyEmail: (params: {
|
||||
confirmationToken: string;
|
||||
}) => Promise<{
|
||||
team: Team;
|
||||
account: Account;
|
||||
}>;
|
||||
resendConfirmationEmail: () => Promise<void>;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
accountAddress: string;
|
||||
title: string;
|
||||
trackingAction: string;
|
||||
};
|
||||
|
||||
export function VerifyEmail(props: VerifyEmailProps) {
|
||||
const form = useForm<EmailConfirmationValidationSchema>({
|
||||
resolver: zodResolver(emailConfirmationValidationSchema),
|
||||
values: {
|
||||
confirmationToken: "",
|
||||
},
|
||||
});
|
||||
|
||||
const verifyEmail = useMutation({
|
||||
mutationFn: props.verifyEmail,
|
||||
});
|
||||
|
||||
const resendConfirmationEmail = useMutation({
|
||||
mutationFn: props.resendConfirmationEmail,
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: props.trackingAction,
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
verifyEmail.mutate(values, {
|
||||
onSuccess: (response) => {
|
||||
props.onEmailConfirmed(response);
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: props.trackingAction,
|
||||
label: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Invalid confirmation code");
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: props.trackingAction,
|
||||
label: "error",
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function handleResend() {
|
||||
form.setValue("confirmationToken", "");
|
||||
verifyEmail.reset();
|
||||
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: "resendEmailConfirmation",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
resendConfirmationEmail.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success("Verification code sent");
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: "resendEmailConfirmation",
|
||||
label: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to send verification code");
|
||||
props.trackEvent({
|
||||
category: "account",
|
||||
action: "resendEmailConfirmation",
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-4 py-6 lg:px-6">
|
||||
<h3 className="mb-1 font-semibold text-foreground text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="mb-6 text-muted-foreground text-sm">
|
||||
Enter the 6 letter confirmation code sent to{" "}
|
||||
<span className="text-medium">{props.email}</span>
|
||||
</p>
|
||||
|
||||
<div className="md:flex md:justify-start">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
inputMode="text"
|
||||
value={form.watch("confirmationToken")}
|
||||
onChange={(otp) => {
|
||||
form.setValue("confirmationToken", otp);
|
||||
}}
|
||||
disabled={verifyEmail.isPending}
|
||||
>
|
||||
<InputOTPGroup className="w-full grow bg-background lg:w-[300px]">
|
||||
{new Array(6).fill(0).map((_, idx) => (
|
||||
<InputOTPSlot
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={idx}
|
||||
index={idx}
|
||||
className={cn("h-12 grow border-foreground/25 text-lg", {
|
||||
"border-red-500":
|
||||
form.getFieldState("confirmationToken", form.formState)
|
||||
.error || verifyEmail.isError,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col-reverse gap-4 border-t px-4 py-6 lg:flex-row lg:justify-between lg:px-6">
|
||||
<Button
|
||||
onClick={props.onBack}
|
||||
variant="outline"
|
||||
className="gap-2 bg-card"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
Change Email
|
||||
</Button>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-background"
|
||||
onClick={handleResend}
|
||||
disabled={resendConfirmationEmail.isPending}
|
||||
>
|
||||
{resendConfirmationEmail.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RotateCcwIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
Resend Code
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={verifyEmail.isPending}
|
||||
className="grow gap-2 px-6"
|
||||
>
|
||||
{verifyEmail.isPending && <Spinner className="size-4" />}
|
||||
Verify
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkWalletVerifyEmail(
|
||||
props: Omit<VerifyEmailProps, "title" | "trackingAction">,
|
||||
) {
|
||||
return (
|
||||
<VerifyEmail
|
||||
{...props}
|
||||
title="Link Wallet"
|
||||
trackingAction="confirmLinkWallet"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SignupVerifyEmail(
|
||||
props: Omit<VerifyEmailProps, "title" | "trackingAction">,
|
||||
) {
|
||||
return (
|
||||
<VerifyEmail
|
||||
{...props}
|
||||
title="Verify Email"
|
||||
trackingAction="confirmEmail"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useState } from "react";
|
||||
import type { TrackingParams } from "../../../hooks/analytics/useTrack";
|
||||
import { LinkWalletPrompt } from "./LinkWalletPrompt/LinkWalletPrompt";
|
||||
import { LoginOrSignup } from "./LoginOrSignup/LoginOrSignup";
|
||||
import {
|
||||
LinkWalletVerifyEmail,
|
||||
SignupVerifyEmail,
|
||||
} from "./VerifyEmail/VerifyEmail";
|
||||
import { AccountOnboardingLayout } from "./onboarding-layout";
|
||||
|
||||
type AccountOnboardingScreen =
|
||||
| { id: "login-or-signup" }
|
||||
| { id: "link-wallet"; email: string; backScreen: AccountOnboardingScreen }
|
||||
| {
|
||||
id: "signup-verify-email";
|
||||
email: string;
|
||||
backScreen: AccountOnboardingScreen;
|
||||
}
|
||||
| {
|
||||
id: "link-wallet-verify-email";
|
||||
email: string;
|
||||
backScreen: AccountOnboardingScreen;
|
||||
};
|
||||
|
||||
type AccountOnboardingProps = {
|
||||
onComplete: (param: {
|
||||
team: Team;
|
||||
account: Account;
|
||||
}) => void;
|
||||
accountAddress: string;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
verifyEmail: (params: {
|
||||
confirmationToken: string;
|
||||
}) => Promise<{
|
||||
team: Team;
|
||||
account: Account;
|
||||
}>;
|
||||
resendEmailConfirmation: () => Promise<void>;
|
||||
loginOrSignup: (input: {
|
||||
email: string;
|
||||
subscribeToUpdates?: true;
|
||||
name?: string;
|
||||
}) => Promise<void>;
|
||||
requestLinkWallet: (email: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function AccountOnboardingUI(props: AccountOnboardingProps) {
|
||||
const [screen, setScreen] = useState<AccountOnboardingScreen>({
|
||||
id: "login-or-signup",
|
||||
});
|
||||
|
||||
return (
|
||||
<AccountOnboardingLayout
|
||||
logout={props.logout}
|
||||
currentStep={
|
||||
screen.id === "login-or-signup" || screen.id === "link-wallet" ? 1 : 2
|
||||
}
|
||||
>
|
||||
{screen.id === "login-or-signup" && (
|
||||
<LoginOrSignup
|
||||
loginOrSignup={props.loginOrSignup}
|
||||
trackEvent={props.trackEvent}
|
||||
onRequestSent={(params) => {
|
||||
if (params.isExistingEmail) {
|
||||
setScreen({
|
||||
id: "link-wallet",
|
||||
email: params.email,
|
||||
backScreen: screen,
|
||||
});
|
||||
} else {
|
||||
setScreen({
|
||||
id: "signup-verify-email",
|
||||
email: params.email,
|
||||
backScreen: screen,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "link-wallet" && (
|
||||
<LinkWalletPrompt
|
||||
accountAddress={props.accountAddress}
|
||||
requestLinkWallet={props.requestLinkWallet}
|
||||
trackEvent={props.trackEvent}
|
||||
onLinkWalletRequestSent={() => {
|
||||
setScreen({
|
||||
id: "link-wallet-verify-email",
|
||||
email: screen.email,
|
||||
backScreen: screen,
|
||||
});
|
||||
}}
|
||||
onBack={() => setScreen(screen.backScreen)}
|
||||
email={screen.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "signup-verify-email" && (
|
||||
<SignupVerifyEmail
|
||||
accountAddress={props.accountAddress}
|
||||
verifyEmail={props.verifyEmail}
|
||||
resendConfirmationEmail={props.resendEmailConfirmation}
|
||||
trackEvent={props.trackEvent}
|
||||
onEmailConfirmed={(data) => {
|
||||
props.onComplete({
|
||||
team: data.team,
|
||||
account: data.account,
|
||||
});
|
||||
}}
|
||||
onBack={() => setScreen(screen.backScreen)}
|
||||
email={screen.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "link-wallet-verify-email" && (
|
||||
<LinkWalletVerifyEmail
|
||||
accountAddress={props.accountAddress}
|
||||
verifyEmail={props.verifyEmail}
|
||||
resendConfirmationEmail={props.resendEmailConfirmation}
|
||||
trackEvent={props.trackEvent}
|
||||
onEmailConfirmed={props.onComplete}
|
||||
onBack={() => setScreen(screen.backScreen)}
|
||||
email={screen.email}
|
||||
/>
|
||||
)}
|
||||
</AccountOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { newAccountStub, teamStub } from "../../../stories/stubs";
|
||||
import { storybookLog } from "../../../stories/utils";
|
||||
import { AccountOnboardingUI } from "./account-onboarding-ui";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/AccountOnboarding/Flow",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const NewEmail: Story = {
|
||||
args: {
|
||||
loginOrSignupType: "success",
|
||||
requestLinkWalletType: "success",
|
||||
verifyEmailType: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmailExists: Story = {
|
||||
args: {
|
||||
loginOrSignupType: "error-email-exists",
|
||||
requestLinkWalletType: "success",
|
||||
verifyEmailType: "success",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
loginOrSignupType: "success" | "error-email-exists" | "error-generic";
|
||||
requestLinkWalletType: "success" | "error";
|
||||
verifyEmailType: "success" | "error";
|
||||
}) {
|
||||
return (
|
||||
<AccountOnboardingUI
|
||||
logout={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
storybookLog("logout");
|
||||
}}
|
||||
onComplete={() => {
|
||||
storybookLog("onComplete");
|
||||
}}
|
||||
accountAddress=""
|
||||
trackEvent={(params) => {
|
||||
storybookLog("trackEvent", params);
|
||||
}}
|
||||
loginOrSignup={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (props.loginOrSignupType === "error-email-exists") {
|
||||
throw new Error("email address already exists");
|
||||
}
|
||||
|
||||
if (props.loginOrSignupType === "error-generic") {
|
||||
throw new Error("generic error");
|
||||
}
|
||||
}}
|
||||
requestLinkWallet={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (props.requestLinkWalletType === "error") {
|
||||
throw new Error("generic error");
|
||||
}
|
||||
}}
|
||||
verifyEmail={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (props.verifyEmailType === "error") {
|
||||
throw new Error("generic error");
|
||||
}
|
||||
|
||||
return {
|
||||
team: teamStub("foo", "free"),
|
||||
account: newAccountStub(),
|
||||
};
|
||||
}}
|
||||
resendEmailConfirmation={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
resendEmailClient,
|
||||
updateAccountClient,
|
||||
verifyEmailClient,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useActiveWallet } from "thirdweb/react";
|
||||
import { useDisconnect } from "thirdweb/react";
|
||||
import { useTrack } from "../../../hooks/analytics/useTrack";
|
||||
import { doLogout } from "../auth-actions";
|
||||
import { AccountOnboardingUI } from "./account-onboarding-ui";
|
||||
|
||||
function AccountOnboarding(props: {
|
||||
onComplete: () => void;
|
||||
onLogout: () => void;
|
||||
accountAddress: string;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
const activeWallet = useActiveWallet();
|
||||
const { disconnect } = useDisconnect();
|
||||
return (
|
||||
<AccountOnboardingUI
|
||||
onComplete={props.onComplete}
|
||||
logout={async () => {
|
||||
if (activeWallet) {
|
||||
disconnect(activeWallet);
|
||||
}
|
||||
await doLogout();
|
||||
props.onLogout();
|
||||
}}
|
||||
accountAddress={props.accountAddress}
|
||||
loginOrSignup={async (params) => {
|
||||
await updateAccountClient(params);
|
||||
}}
|
||||
verifyEmail={verifyEmailClient}
|
||||
resendEmailConfirmation={async () => {
|
||||
await resendEmailClient();
|
||||
}}
|
||||
trackEvent={trackEvent}
|
||||
requestLinkWallet={async (email) => {
|
||||
await updateAccountClient({
|
||||
email,
|
||||
linkWallet: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountOnboarding;
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
|
||||
export function isOnboardingComplete(account: Account) {
|
||||
export function isAccountOnboardingComplete(account: Account) {
|
||||
// if email is confirmed, onboarding is considered complete
|
||||
return !!account.emailConfirmedAt;
|
||||
}
|
||||
|
||||
export function isTeamOnboardingComplete(team: Team) {
|
||||
return team.isOnboarded;
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
import type { RedirectBillingCheckoutAction } from "@/actions/billing";
|
||||
import type { Team } from "@/api/team";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import { OnboardingChoosePlan } from "./ChoosePlan";
|
||||
import { OnboardingConfirmEmail } from "./ConfirmEmail";
|
||||
import { OnboardingGeneral } from "./General";
|
||||
import { OnboardingLinkWallet } from "./LinkWallet";
|
||||
import { useSkipOnboarding } from "./useSkipOnboarding";
|
||||
|
||||
type OnboardingScreen =
|
||||
| { id: "onboarding" }
|
||||
| { id: "linking" }
|
||||
| { id: "confirming" }
|
||||
| { id: "confirmLinking" }
|
||||
| { id: "plan"; team: Team };
|
||||
|
||||
function OnboardingUI(props: {
|
||||
account: Account;
|
||||
onComplete: () => void;
|
||||
onLogout: () => void;
|
||||
// path to redirect from stripe
|
||||
redirectPath: string;
|
||||
redirectToCheckout: RedirectBillingCheckoutAction;
|
||||
skipShowingPlans: boolean;
|
||||
}) {
|
||||
const { account } = props;
|
||||
const [screen, setScreen] = useState<OnboardingScreen>({ id: "onboarding" });
|
||||
|
||||
const trackEvent = useTrack();
|
||||
const [updatedEmail, setUpdatedEmail] = useState<string | undefined>();
|
||||
const skipOnboarding = useSkipOnboarding();
|
||||
|
||||
function trackOnboardingStep(params: {
|
||||
nextStep: OnboardingScreen["id"];
|
||||
email?: string;
|
||||
}) {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardingStep",
|
||||
label: "next",
|
||||
data: {
|
||||
email: params.email || account.unconfirmedEmail || updatedEmail,
|
||||
currentStep: screen,
|
||||
nextStep: params.nextStep,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleDuplicateEmail = (email: string) => {
|
||||
setScreen({
|
||||
id: "linking",
|
||||
});
|
||||
trackOnboardingStep({
|
||||
nextStep: "linking",
|
||||
email,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full rounded-xl border border-border bg-background p-6 shadow-lg",
|
||||
screen.id === "plan" ? "lg:max-w-[750px]" : "lg:w-[500px]",
|
||||
)}
|
||||
>
|
||||
{screen.id === "onboarding" && (
|
||||
<OnboardingGeneral
|
||||
account={account}
|
||||
onLogout={props.onLogout}
|
||||
onSave={(email) => {
|
||||
setUpdatedEmail(email);
|
||||
setScreen({
|
||||
id: "confirming",
|
||||
});
|
||||
trackOnboardingStep({
|
||||
nextStep: "confirming",
|
||||
email,
|
||||
});
|
||||
}}
|
||||
onDuplicate={(email) => {
|
||||
setUpdatedEmail(email);
|
||||
handleDuplicateEmail(email);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "linking" && (
|
||||
<OnboardingLinkWallet
|
||||
onSave={() => {
|
||||
setScreen({
|
||||
id: "confirmLinking",
|
||||
});
|
||||
trackOnboardingStep({
|
||||
nextStep: "confirmLinking",
|
||||
});
|
||||
}}
|
||||
onBack={() => {
|
||||
setUpdatedEmail(undefined);
|
||||
setScreen({
|
||||
id: "onboarding",
|
||||
});
|
||||
}}
|
||||
email={updatedEmail as string}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TODO - separate the confirming and confirmLinking into separate components */}
|
||||
{(screen.id === "confirming" || screen.id === "confirmLinking") && (
|
||||
<OnboardingConfirmEmail
|
||||
linking={screen.id === "confirmLinking"}
|
||||
onComplete={props.onComplete}
|
||||
onEmailConfirm={(res) => {
|
||||
if (screen.id === "confirmLinking") {
|
||||
props.onComplete();
|
||||
} else if (screen.id === "confirming") {
|
||||
if (account.onboardSkipped) {
|
||||
props.onComplete();
|
||||
} else {
|
||||
if (props.skipShowingPlans) {
|
||||
props.onComplete();
|
||||
skipOnboarding();
|
||||
} else {
|
||||
setScreen({ id: "plan", team: res.team });
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBack={() =>
|
||||
setScreen({
|
||||
id: "onboarding",
|
||||
})
|
||||
}
|
||||
email={(account.unconfirmedEmail || updatedEmail) as string}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen.id === "plan" && (
|
||||
<OnboardingChoosePlan
|
||||
redirectPath={props.redirectPath}
|
||||
teamSlug={screen.team.slug}
|
||||
skipPlan={async () => {
|
||||
props.onComplete();
|
||||
skipOnboarding();
|
||||
}}
|
||||
canTrialGrowth={true}
|
||||
redirectToCheckout={props.redirectToCheckout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnboardingUI;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
BoxIcon,
|
||||
LogOutIcon,
|
||||
MailIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
type OnboardingStep = {
|
||||
icon: React.FC<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
number: number;
|
||||
};
|
||||
|
||||
const accountOnboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
icon: UserIcon,
|
||||
title: "Account Details",
|
||||
description: "Provide email address",
|
||||
number: 1,
|
||||
},
|
||||
{
|
||||
icon: MailIcon,
|
||||
title: "Verify Email",
|
||||
description: "Enter your verification code",
|
||||
number: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export function AccountOnboardingLayout(props: {
|
||||
children: React.ReactNode;
|
||||
currentStep: 1 | 2;
|
||||
logout: () => Promise<void>;
|
||||
}) {
|
||||
const logout = useMutation({
|
||||
mutationFn: props.logout,
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
steps={accountOnboardingSteps}
|
||||
currentStep={props.currentStep}
|
||||
title="Get started with thirdweb"
|
||||
cta={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-card px-4"
|
||||
onClick={() => {
|
||||
logout.mutate();
|
||||
}}
|
||||
>
|
||||
{logout.isPending ? (
|
||||
<Spinner className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<LogOutIcon className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
Logout
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const teamOnboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
icon: BoxIcon,
|
||||
title: "Team Details",
|
||||
description: "Provide team details",
|
||||
number: 1,
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: "Team Members",
|
||||
description: "Invite members to your team",
|
||||
number: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamOnboardingLayout(props: {
|
||||
children: React.ReactNode;
|
||||
currentStep: 1 | 2;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingLayout
|
||||
steps={teamOnboardingSteps}
|
||||
currentStep={props.currentStep}
|
||||
title="Setup your team"
|
||||
>
|
||||
{props.children}
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingLayout(props: {
|
||||
steps: OnboardingStep[];
|
||||
currentStep: number;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
cta?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container flex items-center justify-between gap-6">
|
||||
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
{props.title}
|
||||
</h1>
|
||||
{props.cta}
|
||||
</div>
|
||||
</div>
|
||||
<div className="container flex grow flex-col gap-10 xl:flex-row">
|
||||
{/* Left */}
|
||||
<div className="flex w-full max-w-[850px] flex-col py-8">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
<div className="hidden shrink-0 grow flex-col xl:flex">
|
||||
{/* Steps */}
|
||||
<div className="relative flex grow flex-col gap-7 pt-8">
|
||||
{/* Timeline line */}
|
||||
<div className="-translate-x-1/2 absolute top-0 bottom-0 left-5 border-border border-l border-dashed" />
|
||||
|
||||
{props.steps.map((step) => {
|
||||
const isInactive = step.number !== props.currentStep;
|
||||
return (
|
||||
<div
|
||||
key={step.number}
|
||||
className={cn("flex items-center gap-4")}
|
||||
>
|
||||
<div className="relative z-10 flex size-10 items-center justify-center rounded-full border bg-card">
|
||||
<step.icon
|
||||
className={cn(
|
||||
"size-5 text-foreground",
|
||||
isInactive && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn("flex flex-col", isInactive && "opacity-50")}
|
||||
>
|
||||
<h3 className="font-semibold">{step.title}</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { teamStub } from "../../../../stories/stubs";
|
||||
import { storybookLog } from "../../../../stories/utils";
|
||||
import { TeamOnboardingLayout } from "../onboarding-layout";
|
||||
import { InviteTeamMembersUI } from "./InviteTeamMembers";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/TeamOnboarding/InviteTeamMembers",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FreePlan: Story = {
|
||||
args: {
|
||||
plan: "free",
|
||||
},
|
||||
};
|
||||
|
||||
// This is the case when user returns back to team onboarding flow from stripe
|
||||
export const StarterPlan: Story = {
|
||||
args: {
|
||||
plan: "starter",
|
||||
},
|
||||
};
|
||||
|
||||
export const GrowthPlan: Story = {
|
||||
args: {
|
||||
plan: "growth",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
plan: "free" | "growth" | "starter";
|
||||
}) {
|
||||
return (
|
||||
<TeamOnboardingLayout currentStep={2}>
|
||||
<InviteTeamMembersUI
|
||||
trackEvent={(params) => {
|
||||
storybookLog("trackEvent", params);
|
||||
}}
|
||||
getTeam={async () => {
|
||||
return teamStub("foo", props.plan);
|
||||
}}
|
||||
team={teamStub("foo", props.plan)}
|
||||
inviteTeamMembers={async (params) => {
|
||||
return { results: params.map(() => "fulfilled") };
|
||||
}}
|
||||
getBillingCheckoutUrl={async () => {
|
||||
return { status: 200 };
|
||||
}}
|
||||
onComplete={() => {
|
||||
storybookLog("onComplete");
|
||||
}}
|
||||
/>
|
||||
</TeamOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import type { GetBillingCheckoutUrlAction } from "@/actions/billing";
|
||||
import type { Team } from "@/api/team";
|
||||
import { PricingCard } from "@/components/blocks/pricing-card";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
|
||||
import { pollWithTimeout } from "../../../../utils/pollWithTimeout";
|
||||
import { useStripeRedirectEvent } from "../../../stripe-redirect/stripeRedirectChannel";
|
||||
import {
|
||||
InviteSection,
|
||||
type InviteTeamMembersFn,
|
||||
} from "../../../team/[team_slug]/(team)/~/settings/members/InviteSection";
|
||||
|
||||
export function InviteTeamMembersUI(props: {
|
||||
team: Team;
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
inviteTeamMembers: InviteTeamMembersFn;
|
||||
onComplete: () => void;
|
||||
getTeam: () => Promise<Team>;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
}) {
|
||||
const [showPlanModal, setShowPlanModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useDashboardRouter();
|
||||
const [isPollingTeam, setIsPollingTeam] = useState(false);
|
||||
const [hasSentInvites, setHasSentInvites] = useState(false);
|
||||
|
||||
const showSpinner = isPollingTeam || isPending;
|
||||
|
||||
useStripeRedirectEvent(async () => {
|
||||
setShowPlanModal(false);
|
||||
setIsPollingTeam(true);
|
||||
|
||||
// poll until the team has a non-free billing plan with a timeout of 5 seconds
|
||||
await pollWithTimeout({
|
||||
shouldStop: async () => {
|
||||
const team = await props.getTeam();
|
||||
const isNonFreePlan = team.billingPlan !== "free";
|
||||
|
||||
if (isNonFreePlan) {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "upgradePlan",
|
||||
label: "success",
|
||||
plan: team.billingPlan,
|
||||
});
|
||||
}
|
||||
|
||||
return isNonFreePlan;
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
setIsPollingTeam(false);
|
||||
|
||||
// refresh the page to get the latest team data
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col">
|
||||
<Dialog open={showPlanModal} onOpenChange={setShowPlanModal}>
|
||||
<DialogContent className="lg:max-w-[750px]">
|
||||
<InviteModalContent
|
||||
teamSlug={props.team.slug}
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
trackEvent={props.trackEvent}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InviteSection
|
||||
inviteTeamMembers={props.inviteTeamMembers}
|
||||
team={props.team}
|
||||
userHasEditPermission={true}
|
||||
onInviteSuccess={() => setHasSentInvites(true)}
|
||||
shouldHideInviteButton={hasSentInvites}
|
||||
customCTASection={
|
||||
<div className="flex gap-3">
|
||||
{props.team.billingPlan === "free" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
setShowPlanModal(true);
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "upgradePlan",
|
||||
label: "openModal",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CircleArrowUpIcon className="size-4" />
|
||||
Upgrade
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
props.onComplete();
|
||||
if (!hasSentInvites) {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "inviteTeamMembers",
|
||||
label: "skip",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="gap-2"
|
||||
size="sm"
|
||||
variant={hasSentInvites ? "default" : "outline"}
|
||||
>
|
||||
{hasSentInvites ? "Continue to dashboard" : "Skip for now"}
|
||||
{hasSentInvites && <ArrowRightIcon className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showSpinner && (
|
||||
<div className="absolute top-6 right-6">
|
||||
<Spinner className="size-5" />{" "}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteModalContent(props: {
|
||||
teamSlug: string;
|
||||
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
|
||||
trackEvent: (params: TrackingParams) => void;
|
||||
}) {
|
||||
const [planToShow, setPlanToShow] = useState<"starter" | "growth">("starter");
|
||||
|
||||
const starterPlan = (
|
||||
<PricingCard
|
||||
billingPlan="starter"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started for free",
|
||||
onClick() {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "upgradePlan",
|
||||
label: "attempt",
|
||||
plan: "starter",
|
||||
});
|
||||
},
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "starterPlan",
|
||||
},
|
||||
}}
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
const growthPlan = (
|
||||
<PricingCard
|
||||
billingPlan="growth"
|
||||
teamSlug={props.teamSlug}
|
||||
cta={{
|
||||
title: "Get Started with Growth",
|
||||
tracking: {
|
||||
category: "account",
|
||||
label: "growthPlan",
|
||||
},
|
||||
onClick() {
|
||||
props.trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "upgradePlan",
|
||||
label: "attempt",
|
||||
plan: "growth",
|
||||
});
|
||||
},
|
||||
variant: "default",
|
||||
}}
|
||||
highlighted
|
||||
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Choose a plan</DialogTitle>
|
||||
|
||||
<DialogDescription className="leading-relaxed">
|
||||
Get started with the free Starter plan or upgrade to Growth plan for
|
||||
increased limits and advanced features.{" "}
|
||||
<UnderlineLink href="https://thirdweb.com/pricing" target="_blank">
|
||||
Learn more about pricing
|
||||
</UnderlineLink>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden grid-cols-1 gap-6 md:grid-cols-2 md:gap-4 lg:grid">
|
||||
{starterPlan}
|
||||
{growthPlan}
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="lg:hidden">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "Starter",
|
||||
onClick: () => setPlanToShow("starter"),
|
||||
isActive: planToShow === "starter",
|
||||
},
|
||||
{
|
||||
name: "Growth",
|
||||
onClick: () => setPlanToShow("growth"),
|
||||
isActive: planToShow === "growth",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="h-4" />
|
||||
{planToShow === "starter" && starterPlan}
|
||||
{planToShow === "growth" && growthPlan}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { teamStub } from "../../../../stories/stubs";
|
||||
import { storybookLog } from "../../../../stories/utils";
|
||||
import { TeamOnboardingLayout } from "../onboarding-layout";
|
||||
import { TeamInfoFormUI } from "./TeamInfoForm";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/TeamOnboarding/TeamInfo",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SendSuccess: Story = {
|
||||
args: {
|
||||
sendType: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const SendError: Story = {
|
||||
args: {
|
||||
sendType: "error",
|
||||
},
|
||||
};
|
||||
|
||||
function Story(props: {
|
||||
sendType: "success" | "error";
|
||||
}) {
|
||||
return (
|
||||
<TeamOnboardingLayout currentStep={1}>
|
||||
<TeamInfoFormUI
|
||||
onComplete={() => {
|
||||
storybookLog("onComplete");
|
||||
}}
|
||||
teamSlug="foo"
|
||||
isTeamSlugAvailable={async (slug) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// taken slugs stub
|
||||
if (slug === "xyz" || slug === "abc" || slug === "xyz-1") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
updateTeam={async (formData) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
storybookLog("updateTeam", formData);
|
||||
if (props.sendType === "error") {
|
||||
throw new Error("Test Error");
|
||||
}
|
||||
|
||||
return teamStub("foo", "free");
|
||||
}}
|
||||
/>
|
||||
<Toaster richColors />
|
||||
</TeamOnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { z } from "zod";
|
||||
import { teamSlugRegex } from "../../../team/[team_slug]/(team)/~/settings/general/common";
|
||||
|
||||
type TeamData = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
image?: File;
|
||||
};
|
||||
|
||||
const teamSlugSchema = z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: "URL must be at least 3 characters",
|
||||
})
|
||||
.max(48, {
|
||||
message: "URL must be at most 48 characters",
|
||||
})
|
||||
.refine((slug) => !teamSlugRegex.test(slug), {
|
||||
message: "URL can only contain lowercase letters, numbers and hyphens",
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: "Name must be at least 3 characters",
|
||||
})
|
||||
.max(32, {
|
||||
message: "Name must be at most 32 characters",
|
||||
}),
|
||||
slug: teamSlugSchema,
|
||||
image: z.instanceof(File).optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function TeamInfoFormUI(props: {
|
||||
updateTeam: (data: TeamData) => Promise<Team>;
|
||||
onComplete: (updatedTeam: Team) => void;
|
||||
isTeamSlugAvailable: (slug: string) => Promise<boolean>;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
image: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const [disableSlugSuggestion, setDisableSlugSuggestion] = useState(false);
|
||||
const name = form.watch("name");
|
||||
const slug = form.watch("slug");
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const [debouncedName] = useDebounce(name, 500);
|
||||
|
||||
const { data: suggestedSlug, isFetching: isCalculatingSlug } = useQuery({
|
||||
queryKey: ["suggest-team-slug", debouncedName],
|
||||
queryFn: async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const computedSlug = computeSlug(name, attempt);
|
||||
if (teamSlugSchema.safeParse(computedSlug).error) {
|
||||
return null;
|
||||
}
|
||||
const isAvailable = await props.isTeamSlugAvailable(computedSlug);
|
||||
|
||||
if (isAvailable) {
|
||||
return computedSlug;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
enabled: !!debouncedName && !disableSlugSuggestion,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (suggestedSlug && !disableSlugSuggestion) {
|
||||
form.setValue("slug", suggestedSlug, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}, [suggestedSlug, form, disableSlugSuggestion]);
|
||||
|
||||
const shouldValidateSlug =
|
||||
!!debouncedSlug &&
|
||||
!form.getFieldState("slug").invalid &&
|
||||
debouncedSlug !== props.teamSlug &&
|
||||
suggestedSlug !== debouncedSlug;
|
||||
|
||||
const { data: isSlugAvailable, isFetching: isCheckingSlug } = useQuery({
|
||||
queryKey: ["checkTeamSlug", debouncedSlug],
|
||||
queryFn: () => {
|
||||
return props.isTeamSlugAvailable(debouncedSlug);
|
||||
},
|
||||
enabled: shouldValidateSlug,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (isSlugAvailable === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!isSlugAvailable) {
|
||||
form.setError("slug", {
|
||||
type: "manual",
|
||||
message: "This URL is already taken",
|
||||
});
|
||||
} else {
|
||||
const isValidSlugError = form.formState.errors.slug?.type === "manual";
|
||||
if (isValidSlugError) {
|
||||
form.clearErrors("slug");
|
||||
}
|
||||
}
|
||||
}, [isSlugAvailable, form]);
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: props.updateTeam,
|
||||
});
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
submit.mutate(values, {
|
||||
onError(error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to submit team details");
|
||||
},
|
||||
onSuccess(updatedTeam) {
|
||||
props.onComplete(updatedTeam);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card ">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6 p-4 lg:p-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormLabel>Team Avatar</FormLabel>
|
||||
<FormDescription>
|
||||
Enhance your team profile by adding a custom avatar
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<div className="h-3" />
|
||||
<FormControl>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
value={value}
|
||||
setValue={(file) => onChange(file)}
|
||||
className="w-28 rounded-full bg-background"
|
||||
disableHelperText
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="h-3" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Team Name
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Company Inc."
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your team's name on thirdweb
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Team URL
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative flex rounded-lg border border-border">
|
||||
<div className="flex items-center self-stretch rounded-l-lg border-border border-r bg-card px-3 font-mono text-muted-foreground/80 text-sm">
|
||||
thirdweb.com/team/
|
||||
</div>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setDisableSlugSuggestion(true);
|
||||
field.onChange(e);
|
||||
}}
|
||||
className="truncate border-0 font-mono"
|
||||
placeholder="my-team"
|
||||
/>
|
||||
{(isCheckingSlug || isCalculatingSlug) && (
|
||||
<div className="-translate-y-1/2 fade-in-0 absolute top-1/2 right-3 duration-300">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your team's URL namespace on thirdweb
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t p-4 lg:px-6">
|
||||
<Button type="submit" className="gap-2" size="sm">
|
||||
Continue
|
||||
{submit.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ArrowRightIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const computeSlug = (name: string, attempt: number) => {
|
||||
const baseSlug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return attempt === 0 ? baseSlug : `${baseSlug}-${attempt}`;
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { getBillingCheckoutUrl } from "@/actions/billing";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { sendTeamInvites } from "@/actions/sendTeamInvite";
|
||||
import type { Team } from "@/api/team";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { useTrack } from "../../../../hooks/analytics/useTrack";
|
||||
import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam";
|
||||
import { InviteTeamMembersUI } from "./InviteTeamMembers";
|
||||
import { TeamInfoFormUI } from "./TeamInfoForm";
|
||||
|
||||
export function TeamInfoForm(props: {
|
||||
client: ThirdwebClient;
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
return (
|
||||
<TeamInfoFormUI
|
||||
isTeamSlugAvailable={async (slug) => {
|
||||
const res = await apiServerProxy<{
|
||||
result: boolean;
|
||||
}>({
|
||||
pathname: "/v1/teams/check-slug",
|
||||
searchParams: {
|
||||
slug,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}}
|
||||
teamSlug={props.teamSlug}
|
||||
onComplete={(updatedTeam) => {
|
||||
router.replace(`/get-started/team/${updatedTeam.slug}/add-members`);
|
||||
}}
|
||||
updateTeam={async (data) => {
|
||||
const teamValue: Partial<Team> = {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
};
|
||||
|
||||
trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "updateTeam",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
if (data.image) {
|
||||
try {
|
||||
teamValue.image = await upload({
|
||||
client: props.client,
|
||||
files: [data.image],
|
||||
});
|
||||
} catch {
|
||||
// If image upload fails - ignore image, continue with the rest of the update
|
||||
toast.error("Failed to upload image");
|
||||
}
|
||||
}
|
||||
|
||||
const res = await updateTeam({
|
||||
teamId: props.teamId,
|
||||
value: teamValue,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "updateTeam",
|
||||
label: "error",
|
||||
});
|
||||
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "updateTeam",
|
||||
label: "success",
|
||||
});
|
||||
|
||||
return res.data;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteTeamMembers(props: {
|
||||
team: Team;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<InviteTeamMembersUI
|
||||
trackEvent={trackEvent}
|
||||
onComplete={() => {
|
||||
router.replace(`/team/${props.team.slug}`);
|
||||
}}
|
||||
getTeam={async () => {
|
||||
const res = await apiServerProxy<{
|
||||
result: Team;
|
||||
}>({
|
||||
pathname: `/v1/teams/${props.team.slug}`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}}
|
||||
team={props.team}
|
||||
getBillingCheckoutUrl={getBillingCheckoutUrl}
|
||||
inviteTeamMembers={async (params) => {
|
||||
const res = await sendTeamInvites({
|
||||
teamId: props.team.id,
|
||||
invites: params,
|
||||
});
|
||||
|
||||
trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "inviteTeamMembers",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "inviteTeamMembers",
|
||||
label: "error",
|
||||
});
|
||||
throw new Error(res.errorMessage);
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "teamOnboarding",
|
||||
action: "inviteTeamMembers",
|
||||
label: "success",
|
||||
});
|
||||
|
||||
return {
|
||||
results: res.results,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
|
||||
export function useSkipOnboarding() {
|
||||
const mutation = useUpdateAccount();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
async function skipOnboarding() {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardSkippedBilling",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
return mutation.mutateAsync(
|
||||
{
|
||||
onboardSkipped: true,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardSkippedBilling",
|
||||
label: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
category: "account",
|
||||
action: "onboardSkippedBilling",
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return skipOnboarding;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { RE_EMAIL } from "utils/regex";
|
||||
import { z } from "zod";
|
||||
|
||||
const nameValidation = z
|
||||
@@ -6,12 +5,10 @@ const nameValidation = z
|
||||
.min(3, { message: "Must be at least 3 chars" })
|
||||
.max(64, { message: "Must be max 64 chars" });
|
||||
|
||||
const emailValidation = z.string().refine((str) => RE_EMAIL.test(str), {
|
||||
message: "Email address is not valid",
|
||||
});
|
||||
export const emailSchema = z.string().email("Invalid email address");
|
||||
|
||||
export const accountValidationSchema = z.object({
|
||||
email: emailValidation,
|
||||
email: emailSchema,
|
||||
name: nameValidation.or(z.literal("")),
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export function ChatPageContent(props: {
|
||||
const setContextFilters = useCallback((v: NebulaContext | undefined) => {
|
||||
_setContextFilters(v);
|
||||
setHasUserUpdatedContextFilters(true);
|
||||
saveLastUsedChainIds(v?.chainIds || undefined);
|
||||
}, []);
|
||||
|
||||
const isNewSession = !props.session;
|
||||
@@ -118,7 +119,21 @@ export function ChatPageContent(props: {
|
||||
walletAddress: null,
|
||||
};
|
||||
|
||||
// Only set wallet address from connected wallet
|
||||
updatedContextFilters.walletAddress = address || null;
|
||||
|
||||
// if we have last used chains in storage, continue using them
|
||||
try {
|
||||
const lastUsedChainIds = getLastUsedChainIds();
|
||||
if (lastUsedChainIds) {
|
||||
updatedContextFilters.chainIds = lastUsedChainIds;
|
||||
return updatedContextFilters;
|
||||
}
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
|
||||
// else - use the active chain
|
||||
updatedContextFilters.chainIds = activeChain
|
||||
? [activeChain.id.toString()]
|
||||
: [];
|
||||
@@ -493,3 +508,31 @@ function WalletDisconnectedDialog(props: {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const NEBULA_LAST_USED_CHAIN_IDS_KEY = "nebula-last-used-chain-ids";
|
||||
|
||||
function saveLastUsedChainIds(chainIds: string[] | undefined) {
|
||||
try {
|
||||
if (chainIds && chainIds.length > 0) {
|
||||
localStorage.setItem(
|
||||
NEBULA_LAST_USED_CHAIN_IDS_KEY,
|
||||
JSON.stringify(chainIds),
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(NEBULA_LAST_USED_CHAIN_IDS_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function getLastUsedChainIds(): string[] | null {
|
||||
try {
|
||||
const lastUsedChainIdsStr = localStorage.getItem(
|
||||
NEBULA_LAST_USED_CHAIN_IDS_KEY,
|
||||
);
|
||||
return lastUsedChainIdsStr ? JSON.parse(lastUsedChainIdsStr) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useEffect } from "react";
|
||||
import { stripeRedirectPageChannel } from "./stripeRedirectChannel";
|
||||
|
||||
export default function ClosePage() {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
stripeRedirectPageChannel.postMessage("close");
|
||||
// this will only work if this page is opened as a new tab
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<div className="flex flex-col items-center text-center text-sm">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const stripeRedirectPageChannel = new BroadcastChannel(
|
||||
"stripe-redirect",
|
||||
);
|
||||
|
||||
export function useStripeRedirectEvent(cb?: () => void) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!cb) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (!cb) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === "close") {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
stripeRedirectPageChannel.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
stripeRedirectPageChannel.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [cb]);
|
||||
|
||||
return null;
|
||||
}
|
||||
+5
-2
@@ -8,6 +8,9 @@ const meta = {
|
||||
title: "Banners/Billing Alerts",
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
@@ -20,12 +23,12 @@ export const PaymentAlerts: Story = {
|
||||
<div className="space-y-10">
|
||||
<PastDueBannerUI
|
||||
teamSlug="foo"
|
||||
redirectToBillingPortal={() => Promise.resolve({ status: 200 })}
|
||||
getBillingPortalUrl={() => Promise.resolve({ status: 200 })}
|
||||
/>
|
||||
|
||||
<ServiceCutOffBannerUI
|
||||
teamSlug="foo"
|
||||
redirectToBillingPortal={() => Promise.resolve({ status: 200 })}
|
||||
getBillingPortalUrl={() => Promise.resolve({ status: 200 })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { redirectToBillingPortal } from "@/actions/billing";
|
||||
import { getBillingPortalUrl } from "@/actions/billing";
|
||||
import {
|
||||
PastDueBannerUI,
|
||||
ServiceCutOffBannerUI,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
export function PastDueBanner(props: { teamSlug: string }) {
|
||||
return (
|
||||
<PastDueBannerUI
|
||||
redirectToBillingPortal={redirectToBillingPortal}
|
||||
getBillingPortalUrl={getBillingPortalUrl}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
);
|
||||
@@ -18,7 +18,7 @@ export function PastDueBanner(props: { teamSlug: string }) {
|
||||
export function ServiceCutOffBanner(props: { teamSlug: string }) {
|
||||
return (
|
||||
<ServiceCutOffBannerUI
|
||||
redirectToBillingPortal={redirectToBillingPortal}
|
||||
getBillingPortalUrl={getBillingPortalUrl}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
);
|
||||
|
||||
+31
-21
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { BillingBillingPortalAction } from "@/actions/billing";
|
||||
import { BillingPortalButton } from "@/components/billing";
|
||||
import type { GetBillingPortalUrlAction } from "@/actions/billing";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTransition } from "react";
|
||||
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
|
||||
|
||||
function BillingAlertBanner(props: {
|
||||
title: string;
|
||||
@@ -12,23 +15,38 @@ function BillingAlertBanner(props: {
|
||||
teamSlug: string;
|
||||
variant: "error" | "warning";
|
||||
ctaLabel: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
}) {
|
||||
const [isRouteLoading, setIsRouteLoading] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
useStripeRedirectEvent(() => {
|
||||
setTimeout(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col border-b bg-card px-4 py-6 lg:items-center lg:text-center",
|
||||
"relative flex flex-col border-b bg-card px-4 py-6 lg:items-center lg:text-center",
|
||||
props.variant === "warning" &&
|
||||
"border-yellow-600 bg-yellow-50 text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950 dark:text-yellow-100",
|
||||
props.variant === "error" &&
|
||||
"border-red-600 bg-red-50 text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-100",
|
||||
)}
|
||||
>
|
||||
{isPending && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
|
||||
<p className="mt-1 mb-4 text-sm">{props.description}</p>
|
||||
<BillingPortalButton
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"gap-2",
|
||||
props.variant === "warning" &&
|
||||
@@ -36,31 +54,24 @@ function BillingAlertBanner(props: {
|
||||
props.variant === "error" &&
|
||||
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
|
||||
)}
|
||||
size="sm"
|
||||
teamSlug={props.teamSlug}
|
||||
redirectPath={`/team/${props.teamSlug}`}
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
onClick={() => {
|
||||
setIsRouteLoading(true);
|
||||
}}
|
||||
>
|
||||
{props.ctaLabel}
|
||||
{isRouteLoading ? <Spinner className="size-4" /> : null}
|
||||
</BillingPortalButton>
|
||||
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
|
||||
{props.ctaLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PastDueBannerUI(props: {
|
||||
teamSlug: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
getBillingPortalUrl: GetBillingPortalUrlAction;
|
||||
}) {
|
||||
return (
|
||||
<BillingAlertBanner
|
||||
ctaLabel="View Invoices"
|
||||
variant="warning"
|
||||
title="Unpaid Invoices"
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
description={
|
||||
<>
|
||||
You have unpaid invoices. Service may be suspended if not paid
|
||||
@@ -74,14 +85,13 @@ export function PastDueBannerUI(props: {
|
||||
|
||||
export function ServiceCutOffBannerUI(props: {
|
||||
teamSlug: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
getBillingPortalUrl: GetBillingPortalUrlAction;
|
||||
}) {
|
||||
return (
|
||||
<BillingAlertBanner
|
||||
ctaLabel="Pay Now"
|
||||
variant="error"
|
||||
title="Service Suspended"
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
description={
|
||||
<>
|
||||
Your service has been suspended due to unpaid invoices. Pay now to
|
||||
|
||||
@@ -63,7 +63,12 @@ export default async function TeamOverviewPage(props: {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-b">
|
||||
<AnalyticsHeader title="Analytics" interval={interval} range={range} />
|
||||
<AnalyticsHeader
|
||||
title="Analytics"
|
||||
interval={interval}
|
||||
range={range}
|
||||
showRangeSelector={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16">
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import {} from "@/components/ui/breadcrumb";
|
||||
import { getAuthToken } from "../../../../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";
|
||||
import { AddPartnerForm } from "../components/client/add-partner-form.client";
|
||||
import { fetchEcosystem } from "../hooks/fetchEcosystem";
|
||||
|
||||
export default async function AddPartnerPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; team_slug: string }>;
|
||||
}) {
|
||||
const { slug, team_slug } = await params;
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${team_slug}/~/ecosystem/${slug}`);
|
||||
}
|
||||
|
||||
const teamSlug = team_slug;
|
||||
const ecosystemSlug = slug;
|
||||
|
||||
try {
|
||||
const ecosystem = await fetchEcosystem({
|
||||
teamIdOrSlug: teamSlug,
|
||||
slug: ecosystemSlug,
|
||||
authToken,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="container">
|
||||
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
|
||||
Add New Partner
|
||||
</h1>
|
||||
<AddPartnerForm ecosystem={ecosystem} authToken={authToken} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error fetching ecosystem:", error);
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-6 font-semibold text-2xl tracking-tight">Error</h1>
|
||||
<p>Could not load ecosystem. Please try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-30
@@ -1,43 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Ecosystem } from "../../../../../types";
|
||||
import { AddPartnerForm } from "./add-partner-form.client";
|
||||
|
||||
export function AddPartnerDialogButton(props: {
|
||||
teamSlug: string;
|
||||
ecosystem: Ecosystem;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const addPartnerUrl = `/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/add-partner`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-2">
|
||||
<DialogTitle className="font-semibold text-2xl tracking-tight">
|
||||
Add Partner
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddPartnerForm
|
||||
ecosystem={props.ecosystem}
|
||||
onPartnerAdded={() => setOpen(false)}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Link href={addPartnerUrl} passHref>
|
||||
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -21,7 +21,11 @@ export function EcosystemPermissionsPage({
|
||||
/>
|
||||
<AuthOptionsSection ecosystem={ecosystem} authToken={authToken} />
|
||||
{ecosystem?.permission === "PARTNER_WHITELIST" && (
|
||||
<EcosystemPartnersSection ecosystem={ecosystem} authToken={authToken} />
|
||||
<EcosystemPartnersSection
|
||||
teamSlug={params.team_slug}
|
||||
ecosystem={ecosystem}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+42
-113
@@ -1,32 +1,22 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
"use client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
import type { Ecosystem } from "../../../../../types";
|
||||
import { partnerFormSchema } from "../../constants";
|
||||
import type { Ecosystem, Partner } from "../../../../../types";
|
||||
import { useAddPartner } from "../../hooks/use-add-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
|
||||
export function AddPartnerForm({
|
||||
ecosystem,
|
||||
onPartnerAdded,
|
||||
authToken,
|
||||
}: { authToken: string; ecosystem: Ecosystem; onPartnerAdded: () => void }) {
|
||||
const form = useForm<z.input<typeof partnerFormSchema>>({
|
||||
resolver: zodResolver(partnerFormSchema),
|
||||
});
|
||||
}: {
|
||||
ecosystem: Ecosystem;
|
||||
authToken: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const params = useParams();
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const { mutateAsync: addPartner, isPending } = useAddPartner(
|
||||
{
|
||||
@@ -34,7 +24,13 @@ export function AddPartnerForm({
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onPartnerAdded();
|
||||
toast.success("Partner added successfully", {
|
||||
description: "The partner has been added to your ecosystem.",
|
||||
});
|
||||
|
||||
// Redirect to the redirect page that will take us back to the configuration page
|
||||
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
|
||||
router.push(redirectPath);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
@@ -46,95 +42,28 @@ export function AddPartnerForm({
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
addPartner({
|
||||
ecosystem,
|
||||
name: values.name,
|
||||
allowlistedDomains: values.domains
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
accessControl: finalAccessControl,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
addPartner({
|
||||
ecosystem,
|
||||
name: values.name,
|
||||
allowlistedDomains: values.domains
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
});
|
||||
})}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel> App Name </FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="App name"
|
||||
{...field}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs transition-all",
|
||||
form.formState.errors.name?.message
|
||||
? "block translate-y-0 text-destructive opacity-100"
|
||||
: "lg:-translate-y-4 hidden opacity-0",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.name?.message}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domains"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel> Domains </FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Domains" className="peer" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Space or comma-separated list of regex domains (e.g.
|
||||
*.example.com)
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bundleIds"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel> Bundle ID </FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Bundle ID" className="peer" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Space or comma-separated list of bundle IDs
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button disabled={isPending} type="submit" className="w-full gap-2">
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<PartnerForm
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isPending}
|
||||
submitLabel="Add"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { type Control, useFieldArray } from "react-hook-form";
|
||||
import { SingleNetworkSelector } from "../../../../../../../../../../../@/components/blocks/NetworkSelectors";
|
||||
import type { PartnerFormValues } from "./partner-form.client";
|
||||
|
||||
type AllowedOperationsSectionProps = {
|
||||
control: Control<PartnerFormValues>;
|
||||
enabled: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export function AllowedOperationsSection({
|
||||
control,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: AllowedOperationsSectionProps) {
|
||||
// Setup field array for allowed operations
|
||||
const allowedOperationsFields = useFieldArray({
|
||||
control,
|
||||
name: "accessControl.allowedOperations",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-6 ">
|
||||
<div>
|
||||
<Label htmlFor="allowed-operations-switch" className="text-base">
|
||||
Allowed Operations
|
||||
</Label>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
Configure which signing operations are allowed for this partner
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allowed-operations-switch"
|
||||
checked={enabled}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="rounded-lg">
|
||||
<div className="flex flex-col gap-6">
|
||||
{allowedOperationsFields.fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="rounded-md border border-border p-4"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.signMethod`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Allowed Signature Method</FormLabel>
|
||||
<FormDescription className="text-xs">
|
||||
Select the types of signatures that are allowed for
|
||||
this partner
|
||||
</FormDescription>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
// Update the signature method
|
||||
field.onChange(value);
|
||||
|
||||
// Reset the operation data based on the selected method
|
||||
if (value === "eth_signTransaction") {
|
||||
// Remove any existing fields for other methods
|
||||
allowedOperationsFields.update(index, {
|
||||
signMethod: value,
|
||||
allowedTransactions: [],
|
||||
});
|
||||
} else if (value === "eth_signTypedData_v4") {
|
||||
allowedOperationsFields.update(index, {
|
||||
signMethod: value,
|
||||
allowedTypedData: [],
|
||||
});
|
||||
} else if (value === "personal_sign") {
|
||||
allowedOperationsFields.update(index, {
|
||||
signMethod: value,
|
||||
allowedPersonalSigns: [],
|
||||
});
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a signature method" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="eth_signTransaction">
|
||||
eth_signTransaction
|
||||
</SelectItem>
|
||||
<SelectItem value="eth_signTypedData_v4">
|
||||
eth_signTypedData_v4
|
||||
</SelectItem>
|
||||
<SelectItem value="personal_sign">
|
||||
personal_sign
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove operation"
|
||||
onClick={() => {
|
||||
allowedOperationsFields.remove(index);
|
||||
}}
|
||||
className="!w-auto ml-4 h-10 px-3"
|
||||
type="button"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Render different fields based on the signature method */}
|
||||
{allowedOperationsFields.fields[index]?.signMethod ===
|
||||
"eth_signTransaction" && (
|
||||
<TransactionRestrictions control={control} index={index} />
|
||||
)}
|
||||
|
||||
{allowedOperationsFields.fields[index]?.signMethod ===
|
||||
"eth_signTypedData_v4" && (
|
||||
<TypedDataRestrictions control={control} index={index} />
|
||||
)}
|
||||
|
||||
{allowedOperationsFields.fields[index]?.signMethod ===
|
||||
"personal_sign" && (
|
||||
<PersonalSignRestrictions control={control} index={index} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
allowedOperationsFields.append({
|
||||
signMethod: "eth_signTransaction",
|
||||
allowedTransactions: [],
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add operation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for eth_signTransaction restrictions
|
||||
function TransactionRestrictions({
|
||||
control,
|
||||
index,
|
||||
}: {
|
||||
control: Control<PartnerFormValues>;
|
||||
index: number;
|
||||
}) {
|
||||
const transactionsArray = useFieldArray({
|
||||
control,
|
||||
name: `accessControl.allowedOperations.${index}.allowedTransactions`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Label className="mb-3 inline-block">Allowed Transactions</Label>
|
||||
<FormDescription className="mb-4 text-xs">
|
||||
Specify the specific transactions patterns that are allowed for this
|
||||
partner. Optional, default is all transactions.
|
||||
</FormDescription>
|
||||
<FormDescription className="mb-4 text-warning-text text-xs">
|
||||
Attention - 'eth_signTransaction' signatures are only for EOA
|
||||
transactions. To specify allowed Smart Account transactions, you need to
|
||||
select the 'eth_personalSign' signature method, with type 'userOp' (user
|
||||
operations).
|
||||
</FormDescription>
|
||||
<div className="flex flex-col gap-4">
|
||||
{transactionsArray.fields.map((field, txIndex) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-4 rounded-md border border-border p-4"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.chainId`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
chainId={field.value}
|
||||
onChange={(chainId) => field.onChange(chainId)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.contractAddress`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contract Address (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.selector`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Function Selector (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.maxValue`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Value in Wei (optional)</FormLabel>
|
||||
<div className="flex">
|
||||
<FormControl>
|
||||
<Input placeholder="0" {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove transaction"
|
||||
onClick={() => {
|
||||
transactionsArray.remove(txIndex);
|
||||
}}
|
||||
className="!w-auto ml-2 px-3"
|
||||
type="button"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
transactionsArray.append({
|
||||
chainId: 1,
|
||||
contractAddress: "",
|
||||
selector: "",
|
||||
maxValue: "",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add transaction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for eth_signTypedData_v4 restrictions
|
||||
function TypedDataRestrictions({
|
||||
control,
|
||||
index,
|
||||
}: {
|
||||
control: Control<PartnerFormValues>;
|
||||
index: number;
|
||||
}) {
|
||||
const typedDataArray = useFieldArray({
|
||||
control,
|
||||
name: `accessControl.allowedOperations.${index}.allowedTypedData`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Label className="mb-3 inline-block">Allowed Typed Data</Label>
|
||||
<FormDescription className="mb-4 text-xs">
|
||||
Specify the specific typed data patterns that are allowed for this
|
||||
partner. Optional, default is any typed data.
|
||||
</FormDescription>
|
||||
<div className="flex flex-col gap-4">
|
||||
{typedDataArray.fields.map((field, dataIndex) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-4 rounded-md border border-border p-4"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.domain`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Domain name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.verifyingContract`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Verifying Contract (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.chainId`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chain (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
chainId={field.value}
|
||||
onChange={(chainId) => field.onChange(chainId)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.primaryType`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Primary Type (optional)</FormLabel>
|
||||
<div className="flex">
|
||||
<FormControl>
|
||||
<Input placeholder="Type" {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove typed data"
|
||||
onClick={() => {
|
||||
typedDataArray.remove(dataIndex);
|
||||
}}
|
||||
className="!w-auto ml-2 px-3"
|
||||
type="button"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
typedDataArray.append({
|
||||
domain: "",
|
||||
verifyingContract: undefined,
|
||||
chainId: undefined,
|
||||
primaryType: undefined,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add typed data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for personal_sign restrictions
|
||||
function PersonalSignRestrictions({
|
||||
control,
|
||||
index,
|
||||
}: {
|
||||
control: Control<PartnerFormValues>;
|
||||
index: number;
|
||||
}) {
|
||||
const personalSignArray = useFieldArray({
|
||||
control,
|
||||
name: `accessControl.allowedOperations.${index}.allowedPersonalSigns`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Label className="mb-3 inline-block">Allowed Personal Sign Data</Label>
|
||||
<FormDescription className="mb-4 text-xs">
|
||||
Specify the types of personal signatures that are allowed for this
|
||||
partner. Optional, default is all personal signatures.
|
||||
</FormDescription>
|
||||
<div className="flex flex-col gap-4">
|
||||
{personalSignArray.fields.map((field, signIndex) => (
|
||||
<div key={field.id} className="rounded-md border border-border p-3">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.messageType`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Message Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
// Update the message type
|
||||
field.onChange(value);
|
||||
|
||||
// Reset the message data based on the selected type
|
||||
if (value === "userOp") {
|
||||
personalSignArray.update(signIndex, {
|
||||
messageType: value,
|
||||
allowedTransactions: [],
|
||||
});
|
||||
} else if (value === "other") {
|
||||
personalSignArray.update(signIndex, {
|
||||
messageType: value,
|
||||
message: "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select message type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="userOp">userOp</SelectItem>
|
||||
<SelectItem value="other">other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove personal sign"
|
||||
onClick={() => {
|
||||
personalSignArray.remove(signIndex);
|
||||
}}
|
||||
className="!w-auto ml-4 h-10 px-3"
|
||||
type="button"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{personalSignArray.fields[signIndex]?.messageType === "userOp" ? (
|
||||
<UserOpTransactions
|
||||
control={control}
|
||||
opIndex={index}
|
||||
signIndex={signIndex}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.message`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Message (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Message pattern" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Specify a message pattern that is allowed
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
personalSignArray.append({
|
||||
messageType: "other",
|
||||
message: "",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add personal sign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for userOp transactions within personal_sign
|
||||
function UserOpTransactions({
|
||||
control,
|
||||
opIndex,
|
||||
signIndex,
|
||||
}: {
|
||||
control: Control<PartnerFormValues>;
|
||||
opIndex: number;
|
||||
signIndex: number;
|
||||
}) {
|
||||
const userOpTransactionsArray = useFieldArray({
|
||||
control,
|
||||
name: `accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Label className="mb-3 inline-block">
|
||||
Allowed User Operations (smart account transactions)
|
||||
</Label>
|
||||
<FormDescription className="mb-4 text-xs">
|
||||
Specify the specific User Operations that are allowed for this partner.
|
||||
Optional, default is all User Operations.
|
||||
</FormDescription>
|
||||
<div className="flex flex-col gap-4">
|
||||
{userOpTransactionsArray.fields.map((field, txIndex) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-4 rounded-md border border-border p-4"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.chainId`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
chainId={field.value}
|
||||
onChange={(chainId) => field.onChange(chainId)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.contractAddress`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contract Address (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.selector`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Function Selector (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.maxValue`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Value in Wei (optional)</FormLabel>
|
||||
<div className="flex">
|
||||
<FormControl>
|
||||
<Input placeholder="0" {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove transaction"
|
||||
onClick={() => {
|
||||
userOpTransactionsArray.remove(txIndex);
|
||||
}}
|
||||
className="!w-auto ml-2 px-3"
|
||||
type="button"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
userOpTransactionsArray.append({
|
||||
chainId: 1,
|
||||
contractAddress: "",
|
||||
selector: "",
|
||||
maxValue: "",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add transaction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
import type { Partner } from "../../../../../types";
|
||||
import { partnerFormSchema } from "../../constants";
|
||||
import { AllowedOperationsSection } from "./allowed-operations-section";
|
||||
|
||||
export type PartnerFormValues = z.infer<typeof partnerFormSchema>;
|
||||
|
||||
type PartnerFormProps = {
|
||||
partner?: Partner; // Optional for add form
|
||||
onSubmit: (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => void;
|
||||
isSubmitting: boolean;
|
||||
submitLabel: string;
|
||||
};
|
||||
|
||||
export function PartnerForm({
|
||||
partner,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
submitLabel,
|
||||
}: PartnerFormProps) {
|
||||
// Check if partner has accessControl and serverVerifier
|
||||
const hasAccessControl = partner ? !!partner.accessControl : false;
|
||||
const hasServerVerifier =
|
||||
hasAccessControl && !!partner?.accessControl?.serverVerifier;
|
||||
const hasAllowedOperations =
|
||||
hasAccessControl && !!partner?.accessControl?.allowedOperations?.length;
|
||||
|
||||
const form = useForm<PartnerFormValues>({
|
||||
resolver: zodResolver(partnerFormSchema),
|
||||
defaultValues: {
|
||||
name: partner?.name || "",
|
||||
domains: partner?.allowlistedDomains.join(",") || "",
|
||||
bundleIds: partner?.allowlistedBundleIds.join(",") || "",
|
||||
// Set the actual accessControl data if it exists
|
||||
accessControl: partner?.accessControl,
|
||||
// Set the UI control properties based on existing data
|
||||
accessControlEnabled: hasAccessControl,
|
||||
serverVerifierEnabled: hasServerVerifier,
|
||||
allowedOperationsEnabled: hasAllowedOperations,
|
||||
},
|
||||
mode: "onChange", // Validate on change for better user experience
|
||||
});
|
||||
|
||||
// Watch the boolean flags for UI state
|
||||
const accessControlEnabled = form.watch("accessControlEnabled");
|
||||
const serverVerifierEnabled = form.watch("serverVerifierEnabled");
|
||||
const allowedOperationsEnabled = form.watch("allowedOperationsEnabled");
|
||||
|
||||
// Setup field array for headers
|
||||
const customHeaderFields = useFieldArray({
|
||||
control: form.control,
|
||||
name: "accessControl.serverVerifier.headers",
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit(
|
||||
(values) => {
|
||||
// Construct the final accessControl object based on the enabled flags
|
||||
let finalAccessControl: Partner["accessControl"] | null = null;
|
||||
|
||||
if (values.accessControlEnabled) {
|
||||
finalAccessControl = {} as Partner["accessControl"];
|
||||
|
||||
if (finalAccessControl && values.serverVerifierEnabled) {
|
||||
finalAccessControl.serverVerifier = {
|
||||
url: values.accessControl?.serverVerifier?.url || "",
|
||||
headers: values.accessControl?.serverVerifier?.headers || [],
|
||||
};
|
||||
}
|
||||
|
||||
if (finalAccessControl && values.allowedOperationsEnabled) {
|
||||
finalAccessControl.allowedOperations =
|
||||
values.accessControl?.allowedOperations || [];
|
||||
}
|
||||
|
||||
// if no values have been set, remove the accessControl object
|
||||
if (
|
||||
finalAccessControl &&
|
||||
Object.keys(finalAccessControl).length === 0
|
||||
) {
|
||||
finalAccessControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(values, finalAccessControl);
|
||||
},
|
||||
(errors) => {
|
||||
// Log validation errors for debugging
|
||||
console.error("Form validation errors:", errors);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel> Name </FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="App name"
|
||||
{...field}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs transition-all",
|
||||
form.formState.errors.name?.message
|
||||
? "block translate-y-0 text-destructive opacity-100"
|
||||
: "hidden opacity-0",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.name?.message}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domains"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 lg:col-span-4">
|
||||
<FormLabel> Domains </FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Domains" className="peer" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"block text-xs",
|
||||
form.formState.errors.domains?.message &&
|
||||
"block translate-y-0 text-destructive opacity-100",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.domains?.message ??
|
||||
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bundleIds"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel> Bundle ID </FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Bundle ID" className="peer" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"block text-xs",
|
||||
form.formState.errors.bundleIds?.message &&
|
||||
"block text-destructive",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.bundleIds?.message ??
|
||||
"Space or comma-separated list of bundle IDs"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div className="mb-4 flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<Label htmlFor="access-control-switch" className="text-base">
|
||||
Access Control
|
||||
</Label>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
Enable access control for this partner
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="access-control-switch"
|
||||
checked={accessControlEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue("accessControlEnabled", checked);
|
||||
// If disabling access control, also disable server verifier and allowed operations
|
||||
if (!checked) {
|
||||
form.setValue("serverVerifierEnabled", false);
|
||||
form.setValue("allowedOperationsEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{accessControlEnabled && (
|
||||
<>
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="server-verifier-switch"
|
||||
className="text-base"
|
||||
>
|
||||
Server Verifier
|
||||
</Label>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
Configure a server verifier for access control
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="server-verifier-switch"
|
||||
checked={serverVerifierEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue("serverVerifierEnabled", checked);
|
||||
|
||||
// Initialize serverVerifier fields if enabling
|
||||
if (
|
||||
checked &&
|
||||
!form.getValues("accessControl.serverVerifier")
|
||||
) {
|
||||
form.setValue("accessControl.serverVerifier", {
|
||||
url: "",
|
||||
headers: [],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{serverVerifierEnabled && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessControl.serverVerifier.url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server Verifier URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://example.com/your-verifier"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs",
|
||||
form.formState.errors.accessControl
|
||||
?.serverVerifier?.url && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.accessControl?.serverVerifier
|
||||
?.url?.message ||
|
||||
"Enter the URL of your server where verification requests will be sent"}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 inline-block">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-4">
|
||||
{customHeaderFields.fields.map((field, headerIdx) => {
|
||||
return (
|
||||
<div className="flex gap-4" key={field.id}>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
{...form.register(
|
||||
`accessControl.serverVerifier.headers.${headerIdx}.key`,
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value"
|
||||
type="text"
|
||||
{...form.register(
|
||||
`accessControl.serverVerifier.headers.${headerIdx}.value`,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Remove header"
|
||||
onClick={() => {
|
||||
customHeaderFields.remove(headerIdx);
|
||||
}}
|
||||
className="!w-auto px-3"
|
||||
type="button"
|
||||
>
|
||||
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-background"
|
||||
onClick={() => {
|
||||
customHeaderFields.append({
|
||||
key: "",
|
||||
value: "",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-muted-foreground text-xs">
|
||||
Set custom headers to be sent along with verification
|
||||
requests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Allowed Operations Section */}
|
||||
<AllowedOperationsSection
|
||||
control={form.control}
|
||||
enabled={allowedOperationsEnabled}
|
||||
onToggle={(checked) => {
|
||||
form.setValue("allowedOperationsEnabled", checked);
|
||||
|
||||
// Initialize allowedOperations array if enabling
|
||||
if (
|
||||
checked &&
|
||||
!form.getValues("accessControl.allowedOperations")
|
||||
) {
|
||||
form.setValue("accessControl.allowedOperations", []);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
className="mt-4 w-full gap-2"
|
||||
>
|
||||
{isSubmitting && <Spinner className="size-4" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
+39
-134
@@ -1,44 +1,24 @@
|
||||
"use client";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
import type { Ecosystem, Partner } from "../../../../../types";
|
||||
import { partnerFormSchema } from "../../constants";
|
||||
import { useUpdatePartner } from "../../hooks/use-update-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
|
||||
export function UpdatePartnerForm({
|
||||
ecosystem,
|
||||
partner,
|
||||
onSuccess,
|
||||
authToken,
|
||||
}: {
|
||||
ecosystem: Ecosystem;
|
||||
partner: Partner;
|
||||
onSuccess: () => void;
|
||||
authToken: string;
|
||||
}) {
|
||||
const form = useForm<z.input<typeof partnerFormSchema>>({
|
||||
resolver: zodResolver(partnerFormSchema),
|
||||
defaultValues: {
|
||||
name: partner.name,
|
||||
domains: partner.allowlistedDomains.join(","),
|
||||
bundleIds: partner.allowlistedBundleIds.join(","),
|
||||
},
|
||||
});
|
||||
const router = useDashboardRouter();
|
||||
const params = useParams();
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
|
||||
{
|
||||
@@ -46,123 +26,48 @@ export function UpdatePartnerForm({
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess();
|
||||
toast.success("Partner updated successfully", {
|
||||
description: "The partner details have been updated.",
|
||||
});
|
||||
|
||||
// Redirect to the redirect page that will take us back to the configuration page
|
||||
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
|
||||
router.push(redirectPath);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add ecosystem partner";
|
||||
: "Failed to update ecosystem partner";
|
||||
toast.error(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
updatePartner({
|
||||
ecosystem,
|
||||
partnerId: partner.id,
|
||||
name: values.name,
|
||||
allowlistedDomains: values.domains
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
});
|
||||
})}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex grow flex-col gap-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel> Name </FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="App name"
|
||||
{...field}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"text-xs transition-all",
|
||||
form.formState.errors.name?.message
|
||||
? "block translate-y-0 text-destructive opacity-100"
|
||||
: "hidden opacity-0",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.name?.message}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domains"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4 lg:col-span-4">
|
||||
<FormLabel> Domains </FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Domains" className="peer" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"block text-xs",
|
||||
form.formState.errors.domains?.message &&
|
||||
"block translate-y-0 text-destructive opacity-100", // If there are errors show them rather than the tip
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.domains?.message ??
|
||||
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bundleIds"
|
||||
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel> Bundle ID </FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Bundle ID" className="peer" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"block text-xs",
|
||||
form.formState.errors.bundleIds?.message &&
|
||||
"block text-destructive",
|
||||
)}
|
||||
>
|
||||
{form.formState.errors.bundleIds?.message ??
|
||||
"Space or comma-separated list of bundle IDs"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
const handleSubmit = (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
updatePartner({
|
||||
ecosystem,
|
||||
partnerId: partner.id,
|
||||
name: values.name,
|
||||
allowlistedDomains: values.domains
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
accessControl: finalAccessControl,
|
||||
});
|
||||
};
|
||||
|
||||
<Button
|
||||
disabled={isPending}
|
||||
type="submit"
|
||||
className="mt-4 w-full gap-2"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
return (
|
||||
<PartnerForm
|
||||
partner={partner}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isPending}
|
||||
submitLabel="Update"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import type { Ecosystem, Partner } from "../../../../../types";
|
||||
import { UpdatePartnerForm } from "./update-partner-form.client";
|
||||
|
||||
export function UpdatePartnerModal({
|
||||
children,
|
||||
ecosystem,
|
||||
partner,
|
||||
authToken,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
ecosystem: Ecosystem;
|
||||
partner: Partner;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-2">
|
||||
<DialogTitle>Update {partner.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UpdatePartnerForm
|
||||
ecosystem={ecosystem}
|
||||
partner={partner}
|
||||
onSuccess={() => setOpen(false)}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+12
-3
@@ -3,9 +3,10 @@ import { AddPartnerDialogButton } from "../client/AddPartnerDialogButton";
|
||||
import { PartnersTable } from "./partners-table";
|
||||
|
||||
export function EcosystemPartnersSection({
|
||||
teamSlug,
|
||||
ecosystem,
|
||||
authToken,
|
||||
}: { ecosystem: Ecosystem; authToken: string }) {
|
||||
}: { teamSlug: string; ecosystem: Ecosystem; authToken: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-6 lg:px-6">
|
||||
<div className="flex flex-col items-start justify-between max-sm:mb-5 lg:flex-row">
|
||||
@@ -21,10 +22,18 @@ export function EcosystemPartnersSection({
|
||||
own app.
|
||||
</p>
|
||||
</div>
|
||||
<AddPartnerDialogButton ecosystem={ecosystem} authToken={authToken} />
|
||||
<AddPartnerDialogButton
|
||||
teamSlug={teamSlug}
|
||||
ecosystem={ecosystem}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartnersTable ecosystem={ecosystem} authToken={authToken} />
|
||||
<PartnersTable
|
||||
ecosystem={ecosystem}
|
||||
authToken={authToken}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+29
-19
@@ -12,20 +12,28 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "../../../../../../../../../../../tw-components/link";
|
||||
import type { Ecosystem, Partner } from "../../../../../types";
|
||||
import { usePartners } from "../../../hooks/use-partners";
|
||||
import { useDeletePartner } from "../../hooks/use-delete-partner";
|
||||
import { UpdatePartnerModal } from "../client/update-partner-modal.client";
|
||||
|
||||
export function PartnersTable({
|
||||
ecosystem,
|
||||
authToken,
|
||||
}: { ecosystem: Ecosystem; authToken: string }) {
|
||||
const { partners, isPending } = usePartners({ ecosystem, authToken });
|
||||
teamSlug,
|
||||
}: {
|
||||
ecosystem: Ecosystem;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const { partners, isPending } = usePartners({
|
||||
ecosystem,
|
||||
authToken,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -59,6 +67,7 @@ export function PartnersTable({
|
||||
partner={partner}
|
||||
ecosystem={ecosystem}
|
||||
authToken={authToken}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -70,8 +79,10 @@ export function PartnersTable({
|
||||
function PartnerRow(props: {
|
||||
partner: Partner;
|
||||
ecosystem: Ecosystem;
|
||||
teamSlug: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const { mutateAsync: deletePartner, isPending: isDeleting } =
|
||||
useDeletePartner(
|
||||
{
|
||||
@@ -117,22 +128,21 @@ function PartnerRow(props: {
|
||||
|
||||
<td className="table-cell py-3 align-middle">
|
||||
<div className="flex justify-end gap-3 pr-3">
|
||||
<UpdatePartnerModal
|
||||
partner={props.partner}
|
||||
ecosystem={props.ecosystem}
|
||||
authToken={props.authToken}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
|
||||
disabled={isDeleting}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/partners/${props.partner.id}/edit`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
</UpdatePartnerModal>
|
||||
<Pencil className="size-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
title={`Are you sure you want to delete the partner ${props.partner.name}?`}
|
||||
description={
|
||||
|
||||
+238
-23
@@ -1,33 +1,248 @@
|
||||
import { isAddress, isHex } from "thirdweb/utils";
|
||||
import z from "zod";
|
||||
|
||||
const isDomainRegex =
|
||||
/^(?:\*|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*(?:\*(?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9](?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*)?)|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]|localhost(?::\d{1,5})?)$/;
|
||||
|
||||
export const partnerFormSchema = z.object({
|
||||
name: z
|
||||
// Define schemas for the different operation types
|
||||
const allowedTransactionSchema = z.object({
|
||||
chainId: z.number().refine(
|
||||
(data) => {
|
||||
if (data !== undefined) {
|
||||
return data > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid chain ID",
|
||||
},
|
||||
),
|
||||
contractAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, {
|
||||
message: "Name must be at least 3 characters",
|
||||
})
|
||||
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
|
||||
message: "Name can only contain letters, numbers and spaces",
|
||||
}),
|
||||
domains: z
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data))
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return isAddress(data);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid contract address",
|
||||
},
|
||||
),
|
||||
selector: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
|
||||
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
|
||||
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
|
||||
})
|
||||
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
|
||||
bundleIds: z
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data))
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return isHex(data) && data.length === 10; // 0x + 4 bytes for the selector (8 chars)
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid function selector",
|
||||
},
|
||||
),
|
||||
maxValue: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) =>
|
||||
s
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0)
|
||||
.join(","),
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data))
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return BigInt(data) >= 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid max value",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const allowedTypedDataSchema = z
|
||||
.object({
|
||||
domain: z.string().refine((data) => data.length > 0, {
|
||||
message: "Domain is required",
|
||||
}),
|
||||
verifyingContract: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data))
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return isAddress(data);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid verifying contract address",
|
||||
},
|
||||
),
|
||||
chainId: z
|
||||
.number()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data !== undefined) {
|
||||
return data > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid chain ID",
|
||||
},
|
||||
),
|
||||
primaryType: z.string().optional(),
|
||||
})
|
||||
.transform((data) => {
|
||||
return {
|
||||
...data,
|
||||
verifyingContract:
|
||||
data.verifyingContract && data.verifyingContract.length > 0
|
||||
? data.verifyingContract
|
||||
: undefined,
|
||||
primaryType:
|
||||
data.primaryType && data.primaryType.length > 0
|
||||
? data.primaryType
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const personalSignRestrictionSchema = z.discriminatedUnion("messageType", [
|
||||
z.object({
|
||||
messageType: z.literal("userOp"),
|
||||
allowedTransactions: z
|
||||
.array(allowedTransactionSchema)
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data)),
|
||||
}),
|
||||
z.object({
|
||||
messageType: z.literal("other"),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
|
||||
z.object({
|
||||
signMethod: z.literal("eth_signTransaction"),
|
||||
allowedTransactions: z
|
||||
.array(allowedTransactionSchema)
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data)),
|
||||
}),
|
||||
z.object({
|
||||
signMethod: z.literal("eth_signTypedData_v4"),
|
||||
allowedTypedData: z
|
||||
.array(allowedTypedDataSchema)
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data)),
|
||||
}),
|
||||
z.object({
|
||||
signMethod: z.literal("personal_sign"),
|
||||
allowedPersonalSigns: z
|
||||
.array(personalSignRestrictionSchema)
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data)),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const partnerFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, {
|
||||
message: "Name must be at least 3 characters",
|
||||
})
|
||||
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
|
||||
message: "Name can only contain letters, numbers and spaces",
|
||||
}),
|
||||
domains: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
|
||||
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
|
||||
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
|
||||
})
|
||||
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
|
||||
bundleIds: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s) =>
|
||||
s
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0)
|
||||
.join(","),
|
||||
),
|
||||
accessControlEnabled: z.boolean().default(false),
|
||||
serverVerifierEnabled: z.boolean().default(false),
|
||||
allowedOperationsEnabled: z.boolean().default(false),
|
||||
accessControl: z
|
||||
.object({
|
||||
serverVerifier: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
allowedOperations: z
|
||||
.array(allowedOperationsSchema)
|
||||
.optional()
|
||||
.transform((data) => (data?.length === 0 ? undefined : data)),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// If serverVerifier is enabled, validate the URL
|
||||
if (data.accessControlEnabled && data.serverVerifierEnabled) {
|
||||
return (
|
||||
data.accessControl?.serverVerifier?.url &&
|
||||
data.accessControl.serverVerifier.url.trim() !== ""
|
||||
);
|
||||
}
|
||||
// Otherwise, no validation needed
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Server Verifier URL is required when Server Verifier is enabled",
|
||||
path: ["accessControl", "serverVerifier", "url"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If serverVerifier is enabled, validate the URL format
|
||||
if (
|
||||
data.accessControlEnabled &&
|
||||
data.serverVerifierEnabled &&
|
||||
data.accessControl?.serverVerifier?.url
|
||||
) {
|
||||
try {
|
||||
new URL(data.accessControl.serverVerifier.url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Otherwise, no validation needed
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid URL",
|
||||
path: ["accessControl", "serverVerifier", "url"],
|
||||
},
|
||||
);
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { Ecosystem } from "../../../../types";
|
||||
|
||||
/**
|
||||
* Fetches ecosystem data from the server
|
||||
*/
|
||||
export async function fetchEcosystem(args: {
|
||||
teamIdOrSlug: string;
|
||||
slug: string;
|
||||
authToken: string;
|
||||
}): Promise<Ecosystem> {
|
||||
const { teamIdOrSlug, slug, authToken } = args;
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
next: {
|
||||
revalidate: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
throw new Error(
|
||||
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystem",
|
||||
);
|
||||
}
|
||||
|
||||
return (await res.json()).result as Ecosystem;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user