Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7bf1ad1d | ||
|
|
21389bbef6 | ||
|
|
4f51201249 | ||
|
|
a0cee488e9 | ||
|
|
0b8907cb7c | ||
|
|
85a78d4460 | ||
|
|
2a6778eb42 | ||
|
|
882fd3dcbd | ||
|
|
744491f853 | ||
|
|
d3f788471b | ||
|
|
6db81ef68d | ||
|
|
f8b2bdc64b | ||
|
|
c407bd10c7 | ||
|
|
e540dc11f5 | ||
|
|
7476b7927d | ||
|
|
ccb641698e | ||
|
|
aa9d858f1c | ||
|
|
d8540216f1 | ||
|
|
654f8795b4 | ||
|
|
279fb918f5 | ||
|
|
c5cff26faa | ||
|
|
78901457cf | ||
|
|
47f8cd6d8d | ||
|
|
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 |
@@ -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;
|
||||
|
||||
+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.483.0",
|
||||
"next": "15.2.3",
|
||||
"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.3",
|
||||
"@next/eslint-plugin-next": "15.2.3",
|
||||
"@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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
+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;
|
||||
};
|
||||
};
|
||||
|
||||
-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"
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ export default async function Page(props: {
|
||||
const events = await getContractEvents({
|
||||
contract: twCloneFactoryContract,
|
||||
events: [ProxyDeployedEvent],
|
||||
blockRange: latestBlockNumber < 2000000n ? latestBlockNumber : 2000000n,
|
||||
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
|
||||
});
|
||||
const event = events.find(
|
||||
(e) =>
|
||||
|
||||
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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} 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";
|
||||
@@ -246,14 +247,17 @@ 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",
|
||||
}}
|
||||
@@ -265,6 +269,11 @@ function CustomConnectEmbed(props: {
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
doLogin: async (params) => {
|
||||
if (isVercel() && !turnstileToken) {
|
||||
setAlwaysShowTurnstile(true);
|
||||
throw new Error("Please complete the captcha.");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await doLogin(params, turnstileToken);
|
||||
if (result.error) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -89,13 +89,11 @@ export function LoginOrSignup(props: {
|
||||
name: "Create account",
|
||||
onClick: () => setTab("signup"),
|
||||
isActive: tab === "signup",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "I already have an account",
|
||||
onClick: () => setTab("login"),
|
||||
isActive: tab === "login",
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -227,13 +227,11 @@ function InviteModalContent(props: {
|
||||
name: "Starter",
|
||||
onClick: () => setPlanToShow("starter"),
|
||||
isActive: planToShow === "starter",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "Growth",
|
||||
onClick: () => setPlanToShow("growth"),
|
||||
isActive: planToShow === "growth",
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"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 { apiServerProxy } from "../../../../@/actions/proxies";
|
||||
import { useTrack } from "../../../../hooks/analytics/useTrack";
|
||||
import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam";
|
||||
import { InviteTeamMembersUI } from "./InviteTeamMembers";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ Send native and ERC-20 currencies
|
||||
|
||||
### Deploy
|
||||
Deploy published contracts
|
||||
- Deploy a Token ERC20 Contract
|
||||
- Deploy a Token ERC20 Contract with name "Hello World" and description "My Hello Contract" on Ethereum.
|
||||
- Deploy a Split contract with two recipients.
|
||||
- Deploy an ERC1155 Contract named 'Hello World' with description 'Hello badges on Ethereum'
|
||||
|
||||
@@ -30,7 +30,7 @@ Deploy published contracts
|
||||
Retrieve information about smart contracts.
|
||||
- What ERC standards are implemented by contract address 0x59325733eb952a92e069C87F0A6168b29E80627f on Ethereum?
|
||||
- What functions can I use to mint more of my contract's NFTs?
|
||||
- What is the total supply of NFTs for my contract?
|
||||
- What is the total supply of NFTs on 0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e?
|
||||
|
||||
### Interact
|
||||
Query wallet balances, addresses, and token holdings.
|
||||
|
||||
+16
-19
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { GetBillingPortalUrlAction } from "@/actions/billing";
|
||||
import { BillingPortalButton } from "@/components/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 Link from "next/link";
|
||||
import { useTransition } from "react";
|
||||
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
|
||||
|
||||
@@ -14,7 +15,6 @@ function BillingAlertBanner(props: {
|
||||
teamSlug: string;
|
||||
variant: "error" | "warning";
|
||||
ctaLabel: string;
|
||||
getBillingPortalUrl: GetBillingPortalUrlAction;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -44,22 +44,21 @@ function BillingAlertBanner(props: {
|
||||
|
||||
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
|
||||
<p className="mt-1 mb-4 text-sm">{props.description}</p>
|
||||
<BillingPortalButton
|
||||
buttonProps={{
|
||||
size: "sm",
|
||||
className: cn(
|
||||
"gap-2",
|
||||
props.variant === "warning" &&
|
||||
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
|
||||
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",
|
||||
),
|
||||
}}
|
||||
teamSlug={props.teamSlug}
|
||||
getBillingPortalUrl={props.getBillingPortalUrl}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"gap-2",
|
||||
props.variant === "warning" &&
|
||||
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{props.ctaLabel}
|
||||
</BillingPortalButton>
|
||||
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
|
||||
{props.ctaLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +72,6 @@ export function PastDueBannerUI(props: {
|
||||
ctaLabel="View Invoices"
|
||||
variant="warning"
|
||||
title="Unpaid Invoices"
|
||||
getBillingPortalUrl={props.getBillingPortalUrl}
|
||||
description={
|
||||
<>
|
||||
You have unpaid invoices. Service may be suspended if not paid
|
||||
@@ -94,7 +92,6 @@ export function ServiceCutOffBannerUI(props: {
|
||||
ctaLabel="Pay Now"
|
||||
variant="error"
|
||||
title="Service Suspended"
|
||||
getBillingPortalUrl={props.getBillingPortalUrl}
|
||||
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;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { Ecosystem, Partner } from "../../../../types";
|
||||
|
||||
export async function fetchPartnerDetails(args: {
|
||||
authToken: string;
|
||||
ecosystem: Ecosystem;
|
||||
partnerId: string;
|
||||
}): Promise<Partner> {
|
||||
const { authToken, ecosystem, partnerId } = args;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${ecosystem.url}/${ecosystem.id}/partner/${partnerId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch partner details: ${response.status} - ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error fetching partner:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import type { Ecosystem, Partner } from "../../../../types";
|
||||
|
||||
/**
|
||||
* Fetches partners for an ecosystem
|
||||
*/
|
||||
export async function fetchPartners({
|
||||
ecosystem,
|
||||
authToken,
|
||||
}: {
|
||||
ecosystem: Ecosystem;
|
||||
authToken: string;
|
||||
}): Promise<Partner[]> {
|
||||
const res = await fetch(`${ecosystem.url}/${ecosystem.id}/partners`, {
|
||||
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 partners",
|
||||
);
|
||||
}
|
||||
|
||||
const partners = (await res.json()) as Partner[];
|
||||
return partners.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
+2
@@ -10,6 +10,7 @@ type AddPartnerParams = {
|
||||
name: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: Partner["accessControl"] | null;
|
||||
};
|
||||
|
||||
export function useAddPartner(
|
||||
@@ -41,6 +42,7 @@ export function useAddPartner(
|
||||
name: params.name,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
accessControl: params.accessControl ?? undefined,
|
||||
// TODO - remove the requirement for permissions in API endpoint
|
||||
permissions: ["FULL_CONTROL_V1"],
|
||||
}),
|
||||
|
||||
+7
@@ -11,6 +11,12 @@ type UpdatePartnerParams = {
|
||||
name: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: {
|
||||
serverVerifier?: {
|
||||
url: string;
|
||||
headers?: { key: string; value: string }[];
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function useUpdatePartner(
|
||||
@@ -42,6 +48,7 @@ export function useUpdatePartner(
|
||||
name: params.name,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
accessControl: params.accessControl,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import {} from "@/components/ui/breadcrumb";
|
||||
import { getAuthToken } from "../../../../../../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect";
|
||||
import { UpdatePartnerForm } from "../../../components/client/update-partner-form.client";
|
||||
import { fetchEcosystem } from "../../../hooks/fetchEcosystem";
|
||||
import { fetchPartnerDetails } from "../../../hooks/fetchPartnerDetails";
|
||||
|
||||
export default async function EditPartnerPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; team_slug: string; partner_id: string }>;
|
||||
}) {
|
||||
const { slug, team_slug, partner_id } = await params;
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${team_slug}/~/ecosystem/${slug}/configuration`);
|
||||
}
|
||||
|
||||
const teamSlug = team_slug;
|
||||
const ecosystemSlug = slug;
|
||||
const partnerId = partner_id;
|
||||
|
||||
try {
|
||||
const ecosystem = await fetchEcosystem({
|
||||
teamIdOrSlug: teamSlug,
|
||||
slug: ecosystemSlug,
|
||||
authToken,
|
||||
});
|
||||
|
||||
try {
|
||||
// TODO re-enable this once IAW service is re deployed
|
||||
const partner = await fetchPartnerDetails({
|
||||
ecosystem,
|
||||
partnerId,
|
||||
authToken,
|
||||
});
|
||||
// const partners = await fetchPartners({
|
||||
// ecosystem,
|
||||
// authToken,
|
||||
// });
|
||||
|
||||
// const partner = partners.find((p) => p.id === partnerId);
|
||||
|
||||
if (!partner) {
|
||||
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 partner details. Please try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="container">
|
||||
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
|
||||
Edit Partner: {partner.name}
|
||||
</h1>
|
||||
<UpdatePartnerForm
|
||||
ecosystem={ecosystem}
|
||||
partner={partner}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (partnerError) {
|
||||
console.error("Error fetching partner:", partnerError);
|
||||
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 partner details. Please try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (ecosystemError) {
|
||||
console.error("Error fetching ecosystem:", ecosystemError);
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-15
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Ecosystem, Partner } from "../../../types";
|
||||
import { fetchPartners } from "../configuration/hooks/fetchPartners";
|
||||
|
||||
export function usePartners({
|
||||
ecosystem,
|
||||
@@ -8,21 +9,7 @@ export function usePartners({
|
||||
const partnersQuery = useQuery({
|
||||
queryKey: ["ecosystem", ecosystem.id, "partners"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${ecosystem.url}/${ecosystem.id}/partners`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
throw new Error(
|
||||
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystems",
|
||||
);
|
||||
}
|
||||
|
||||
return (await res.json()) as Partner[];
|
||||
return fetchPartners({ ecosystem, authToken });
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -55,4 +55,57 @@ export type Partner = {
|
||||
permissions: [PartnerPermission];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
accessControl?: {
|
||||
serverVerifier?: {
|
||||
url: string;
|
||||
headers?: { key: string; value: string }[];
|
||||
};
|
||||
allowedOperations?: AllowedOperations[];
|
||||
};
|
||||
};
|
||||
|
||||
type AllowedArgument = {
|
||||
offset: number;
|
||||
type: "address" | "uint256" | "bytes32" | "bool" | "string";
|
||||
comparisonOperator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type AllowedTransaction = {
|
||||
chainId: number;
|
||||
contractAddress?: string;
|
||||
selector?: string;
|
||||
arguments?: AllowedArgument[];
|
||||
maxValue?: string;
|
||||
};
|
||||
|
||||
type AllowedTypedData = {
|
||||
domain: string;
|
||||
verifyingContract?: string;
|
||||
chainId?: number;
|
||||
primaryType?: string;
|
||||
};
|
||||
|
||||
type PersonalSignRestriction =
|
||||
| {
|
||||
messageType: "userOp";
|
||||
allowedTransactions?: AllowedTransaction[];
|
||||
}
|
||||
| {
|
||||
messageType: "other";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type AllowedOperations =
|
||||
| {
|
||||
signMethod: "eth_signTransaction";
|
||||
allowedTransactions?: AllowedTransaction[];
|
||||
}
|
||||
| {
|
||||
signMethod: "eth_signTypedData_v4";
|
||||
allowedTypedData?: AllowedTypedData[];
|
||||
}
|
||||
| {
|
||||
signMethod: "personal_sign";
|
||||
allowedPersonalSigns?: PersonalSignRestriction[];
|
||||
};
|
||||
|
||||
-1
@@ -86,7 +86,6 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
|
||||
key,
|
||||
name,
|
||||
isActive: activeTab === key,
|
||||
isEnabled: true,
|
||||
onClick: () => setActiveTab(key),
|
||||
icon:
|
||||
(key === "aws-kms" && !isAwsKmsConfigured) ||
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ export const CredentialForm = ({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
|
||||
href="https://portal.thirdweb.com/engine/features/wallet-credentials"
|
||||
target="_blank"
|
||||
label="learn-more"
|
||||
category="engine"
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export const WalletCredentialsSection: React.FC<WalletCredentialsProps> = ({
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Manage your wallet credentials for different providers.{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
|
||||
href="https://portal.thirdweb.com/engine/features/wallet-credentials"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about wallet credentials.
|
||||
|
||||
@@ -55,7 +55,7 @@ export function SettingsLayout(props: {
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex grow flex-col",
|
||||
"flex max-w-full grow flex-col",
|
||||
// if showing full nav on mobile - hide the page content
|
||||
showFullNavOnMobile && "max-sm:hidden",
|
||||
)}
|
||||
|
||||
+9
-4
@@ -12,13 +12,18 @@ export function getTeamSettingsLinks(teamSlug: string) {
|
||||
group: "team",
|
||||
},
|
||||
{
|
||||
name: "Members",
|
||||
href: `${prefix}/members`,
|
||||
name: "Billing",
|
||||
href: `${prefix}/billing`,
|
||||
group: "team",
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: `${prefix}/billing`,
|
||||
name: "Invoices",
|
||||
href: `${prefix}/invoices`,
|
||||
group: "team",
|
||||
},
|
||||
{
|
||||
name: "Members",
|
||||
href: `${prefix}/members`,
|
||||
group: "team",
|
||||
},
|
||||
{
|
||||
|
||||
+9
-1
@@ -9,6 +9,7 @@ import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { differenceInDays, isAfter } from "date-fns";
|
||||
import { format } from "date-fns/format";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
|
||||
|
||||
export function PlanInfoCard(props: {
|
||||
@@ -53,7 +54,14 @@ export function PlanInfoCard(props: {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{/* go to invoices page */}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/team/${team.slug}/~/settings/invoices`}>
|
||||
View Invoices
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* manage team billing */}
|
||||
<BillingPortalButton
|
||||
teamSlug={team.slug}
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRight,
|
||||
CreditCard,
|
||||
Download,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useTransition } from "react";
|
||||
import type Stripe from "stripe";
|
||||
import { searchParams } from "../search-params";
|
||||
|
||||
export function BillingHistory(props: {
|
||||
invoices: Stripe.Invoice[];
|
||||
hasMore: boolean;
|
||||
}) {
|
||||
console.log(props.invoices.map((i) => i.id));
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
"cursor",
|
||||
searchParams.cursor.withOptions({
|
||||
startTransition,
|
||||
history: "push",
|
||||
shallow: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (invoice: Stripe.Invoice) => {
|
||||
switch (invoice.status) {
|
||||
case "paid":
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">
|
||||
Paid
|
||||
</Badge>
|
||||
);
|
||||
case "open":
|
||||
// we treate "uncollectible" as unpaid
|
||||
case "uncollectible": {
|
||||
// if the invoice due date is in the past, we want to display it as past due
|
||||
if (invoice.due_date && invoice.due_date < Date.now()) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-200 bg-red-50 text-red-800"
|
||||
>
|
||||
Past Due
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="outline">Open</Badge>;
|
||||
}
|
||||
case "void":
|
||||
return <Badge variant="secondary">Void</Badge>;
|
||||
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (props.invoices.length === 0) {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-2 font-medium text-lg">No billing history</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Your invoice history will appear here once you have made payments.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell className="font-medium">#{invoice.number}</TableCell>
|
||||
<TableCell>{formatDate(invoice.created)}</TableCell>
|
||||
<TableCell>
|
||||
{invoice.status === "paid"
|
||||
? formatCurrency(invoice.amount_paid, invoice.currency)
|
||||
: formatCurrency(invoice.amount_due, invoice.currency)}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(invoice)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{invoice.status === "open" &&
|
||||
invoice.hosted_invoice_url && (
|
||||
<Button variant="default" size="sm" asChild>
|
||||
<a
|
||||
href={invoice.hosted_invoice_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Pay Now
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.invoice_pdf && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href={invoice.invoice_pdf}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<hr className="my-4" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// use browser history to go back
|
||||
// this is KINDA hacky but it works (as long as the user doesn't send the URL to someone else...)
|
||||
window.history.back();
|
||||
}}
|
||||
disabled={isLoading || !cursor}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<ChevronLeftIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const lastInvoice = props.invoices[props.invoices.length - 1];
|
||||
if (lastInvoice && props.hasMore) {
|
||||
setCursor(lastInvoice.id);
|
||||
}
|
||||
}}
|
||||
disabled={!props.hasMore || isLoading}
|
||||
>
|
||||
Next
|
||||
{isLoading && props.hasMore ? (
|
||||
<div className="ml-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getTeamInvoices } from "@/actions/stripe-actions";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { SearchParams } from "nuqs/server";
|
||||
import { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
import { BillingHistory } from "./components/billing-history";
|
||||
import { searchParamLoader } from "./search-params";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
searchParamLoader(props.searchParams),
|
||||
]);
|
||||
|
||||
const pagePath = `/team/${params.team_slug}/settings/invoices`;
|
||||
|
||||
const [, team] = await Promise.all([
|
||||
// only called to verify login status etc
|
||||
getValidAccount(pagePath),
|
||||
getTeamBySlug(params.team_slug),
|
||||
]);
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const invoices = await getTeamInvoices(team, {
|
||||
cursor: searchParams.cursor ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invoice History</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
View your past invoices and payment history
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BillingHistory invoices={invoices.data} hasMore={invoices.has_more} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createLoader, parseAsString } from "nuqs/server";
|
||||
|
||||
export const searchParams = {
|
||||
cursor: parseAsString,
|
||||
};
|
||||
|
||||
export const searchParamLoader = createLoader(searchParams);
|
||||
+17
@@ -18,6 +18,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { BASE_URL } from "@/constants/env";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { formatDate } from "date-fns";
|
||||
import { EllipsisIcon, MailIcon } from "lucide-react";
|
||||
@@ -117,6 +118,7 @@ export function ManageInvitesSection(props: {
|
||||
className="border-border border-b last:border-b-0"
|
||||
>
|
||||
<InviteRow
|
||||
teamSlug={props.team.slug}
|
||||
invite={invite}
|
||||
client={props.client}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
@@ -143,6 +145,7 @@ export function ManageInvitesSection(props: {
|
||||
}
|
||||
|
||||
function InviteRow(props: {
|
||||
teamSlug: string;
|
||||
invite: TeamInvite;
|
||||
userHasEditPermission: boolean;
|
||||
client: ThirdwebClient;
|
||||
@@ -198,6 +201,7 @@ function InviteRow(props: {
|
||||
{/* Options */}
|
||||
{props.userHasEditPermission && (
|
||||
<ManageInviteButton
|
||||
teamSlug={props.teamSlug}
|
||||
invite={props.invite}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
deleteInvite={props.deleteInvite}
|
||||
@@ -210,6 +214,7 @@ function InviteRow(props: {
|
||||
}
|
||||
|
||||
function ManageInviteButton(props: {
|
||||
teamSlug: string;
|
||||
invite: TeamInvite;
|
||||
userHasEditPermission: boolean;
|
||||
deleteInvite: (inviteId: string) => Promise<void>;
|
||||
@@ -235,6 +240,18 @@ function ManageInviteButton(props: {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{props.invite.status === "pending" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${BASE_URL}/join/team/${props.teamSlug}/${props.invite.id}`,
|
||||
);
|
||||
toast.success("Invite link copied to clipboard");
|
||||
}}
|
||||
>
|
||||
Copy Invite Link
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
|
||||
-2
@@ -62,13 +62,11 @@ export function TeamMembersSettingsPage(props: {
|
||||
isActive: manageTab === "members",
|
||||
name: "Team Members",
|
||||
onClick: () => setManageTab("members"),
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
isActive: manageTab === "invites",
|
||||
name: "Pending Invites",
|
||||
onClick: () => setManageTab("invites"),
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useState } from "react";
|
||||
|
||||
type TabKey = "ts" | "react" | "react-native" | "dotnet" | "unity" | "unreal";
|
||||
|
||||
const tabNames: Record<TabKey, string> = {
|
||||
ts: "TypeScript",
|
||||
react: "React",
|
||||
"react-native": "React Native",
|
||||
dotnet: ".NET",
|
||||
unity: "Unity",
|
||||
unreal: "Unreal Engine",
|
||||
};
|
||||
|
||||
export function IntegrateAPIKeyCodeTabs(props: {
|
||||
tabs: Record<TabKey, React.ReactNode>;
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>("ts");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabs={Object.entries(tabNames).map(([key, name]) => ({
|
||||
name,
|
||||
onClick: () => setTab(key as TabKey),
|
||||
isActive: tab === key,
|
||||
}))}
|
||||
/>
|
||||
<div className="h-3" />
|
||||
{props.tabs[tab]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { projectStub } from "../../../../../../stories/stubs";
|
||||
import { ProjectFTUX } from "./ProjectFTUX";
|
||||
|
||||
const meta = {
|
||||
title: "Project/ProjectFTUX",
|
||||
component: ProjectFTUX,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container py-8 pb-20">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof ProjectFTUX>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
project: {
|
||||
...projectStub("foo", "bar"),
|
||||
secretKeys: [
|
||||
{
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
hash: "1234567890",
|
||||
masked: "1234567890",
|
||||
},
|
||||
],
|
||||
},
|
||||
teamSlug: "bar",
|
||||
},
|
||||
};
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { CodeServer } from "@/components/ui/code/code.server";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import { ContractIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
|
||||
import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
|
||||
import { InsightIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
|
||||
import { DotNetIcon } from "../../../../../../components/icons/brand-icons/DotNetIcon";
|
||||
import { GithubIcon } from "../../../../../../components/icons/brand-icons/GithubIcon";
|
||||
import { ReactIcon } from "../../../../../../components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "../../../../../../components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "../../../../../../components/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "../../../../../../components/icons/brand-icons/UnrealIcon";
|
||||
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
|
||||
import { SecretKeySection } from "./SecretKeySection";
|
||||
|
||||
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<IntegrateAPIKeySection
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
<ProductsSection
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.project.slug}
|
||||
/>
|
||||
<SDKSection />
|
||||
<StarterKitsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Integrate API key section ------------------------------------------------------------
|
||||
|
||||
function IntegrateAPIKeySection({
|
||||
project,
|
||||
teamSlug,
|
||||
}: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const secretKeyMasked = project.secretKeys[0]?.masked;
|
||||
const clientId = project.publishableKey;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 font-semibold text-xl tracking-tight">
|
||||
Integrate API key
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-6 ">
|
||||
<div>
|
||||
<h3>Client ID</h3>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
Identifies your application.
|
||||
</p>
|
||||
|
||||
<CopyTextButton
|
||||
textToCopy={clientId}
|
||||
className="!h-auto w-full max-w-[350px] justify-between truncate bg-background px-3 py-3 font-mono"
|
||||
textToShow={clientId}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Client ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{secretKeyMasked && (
|
||||
<SecretKeySection
|
||||
secretKeyMasked={secretKeyMasked}
|
||||
projectId={project.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
<IntegrationCodeExamples project={project} teamSlug={teamSlug} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCodeExamples(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<IntegrateAPIKeyCodeTabs
|
||||
tabs={{
|
||||
ts: (
|
||||
<CodeServer
|
||||
code={typescriptCodeExample(props.project)}
|
||||
lang="ts"
|
||||
className="bg-background"
|
||||
/>
|
||||
),
|
||||
react: (
|
||||
<CodeServer
|
||||
code={reactCodeExample(props.project)}
|
||||
lang="ts"
|
||||
className="bg-background"
|
||||
/>
|
||||
),
|
||||
"react-native": (
|
||||
<CodeServer
|
||||
code={reactCodeExample(props.project)}
|
||||
lang="ts"
|
||||
className="bg-background"
|
||||
/>
|
||||
),
|
||||
unity: (
|
||||
<Alert variant="info" className="bg-background">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure Client ID in Thirdweb Manager prefab
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Configure "Client ID" and "Bundle ID" in{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/unity/v5/thirdwebmanager"
|
||||
target="_blank"
|
||||
>
|
||||
Thirdweb Manager prefab
|
||||
</UnderlineLink>
|
||||
<span className="block text-sm">
|
||||
Make sure to configure your app's bundle ID in "Allowed Bundle
|
||||
IDs" in{" "}
|
||||
<UnderlineLink
|
||||
target="_blank"
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
dotnet: (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CodeServer
|
||||
code={dotNotCodeExample(props.project)}
|
||||
lang="csharp"
|
||||
className="bg-background"
|
||||
/>
|
||||
<Alert variant="info" className="bg-background">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure your app's bundle ID in "Allowed Bundle IDs" in
|
||||
Project
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Go to{" "}
|
||||
<UnderlineLink
|
||||
target="_blank"
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>{" "}
|
||||
and add your app's bundle ID to the "Allowed Bundle IDs" list.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
),
|
||||
unreal: (
|
||||
<Alert variant="info" className="bg-background">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure Client ID in Thirdweb Unreal Plugin{" "}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Configure "Client ID" and "Bundle ID" in{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb plugin settings
|
||||
</UnderlineLink>
|
||||
<span className="block text-sm">
|
||||
Make sure to configure your app's bundle ID in "Allowed Bundle
|
||||
IDs" in{" "}
|
||||
<UnderlineLink
|
||||
target="_blank"
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const typescriptCodeExample = (project: Project) => `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
// use clientId for client side usage
|
||||
clientId: "${project.publishableKey}",
|
||||
// use secretKey for server side usage
|
||||
secretKey: "${project.secretKeys[0]?.masked}", // replace this with full secret key
|
||||
});`;
|
||||
|
||||
const reactCodeExample = (project: Project) => `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "${project.publishableKey}",
|
||||
});`;
|
||||
|
||||
const dotNotCodeExample = (project: Project) => `\
|
||||
using Thirdweb;
|
||||
|
||||
// For web applications
|
||||
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}");
|
||||
|
||||
// For native applications - Replace "yourBundleId" with your app's bundle ID
|
||||
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}", bundleId: "yourBundleId");
|
||||
|
||||
// For backend applications (Note: below shown secret key is not the full secret key)
|
||||
var client = ThirdwebClient.Create(secretKey: "${project.secretKeys[0]?.masked}");`;
|
||||
|
||||
// products section ------------------------------------------------------------
|
||||
|
||||
function ProductsSection(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const products: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
trackingLabel: string;
|
||||
}> = [
|
||||
{
|
||||
title: "Engine",
|
||||
description:
|
||||
"Scale your application with a backend server to read, write, and deploy contracts at production-grade.",
|
||||
href: `/team/${props.teamSlug}/~/engine`,
|
||||
icon: EngineIcon,
|
||||
trackingLabel: "engine",
|
||||
},
|
||||
{
|
||||
title: "Contracts",
|
||||
description:
|
||||
"Deploy your own contracts or leverage existing solutions for onchain implementation",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/contracts`,
|
||||
icon: ContractIcon,
|
||||
trackingLabel: "contracts",
|
||||
},
|
||||
{
|
||||
title: "Insight",
|
||||
description:
|
||||
"Add indexing capabilities to retrieve real-time onchain data",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/insight`,
|
||||
icon: InsightIcon,
|
||||
trackingLabel: "insight",
|
||||
},
|
||||
{
|
||||
title: "Nebula",
|
||||
description:
|
||||
"Integrate a blockchain AI model to improve your users insight into your application and the blockchain",
|
||||
href: `/team/${props.teamSlug}/~/nebula`,
|
||||
icon: NebulaIcon,
|
||||
trackingLabel: "nebula",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Complete your full-stack application
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Tools to build frontend, backend, and onchain with built-in
|
||||
infrastructure and analytics.
|
||||
</p>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
{/* Feature Cards */}
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.title}
|
||||
title={product.title}
|
||||
description={product.description}
|
||||
href={product.href}
|
||||
icon={product.icon}
|
||||
trackingLabel={product.trackingLabel}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductCard(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
trackingLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex flex-col rounded-lg border bg-card p-4 hover:border-active-border">
|
||||
<div className="mb-3 flex size-9 items-center justify-center rounded-full border p-1">
|
||||
<props.icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
|
||||
<TrackedLinkTW
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
className="before:absolute before:inset-0"
|
||||
category="project-ftux"
|
||||
label={props.trackingLabel}
|
||||
>
|
||||
{props.title}
|
||||
</TrackedLinkTW>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">{props.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// sdk section ------------------------------------------------------------
|
||||
|
||||
type SDKCardProps = {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
trackingLabel: string;
|
||||
};
|
||||
|
||||
const sdks: SDKCardProps[] = [
|
||||
{
|
||||
name: "TypeScript",
|
||||
href: "https://portal.thirdweb.com/sdk/typescript",
|
||||
icon: TypeScriptIcon,
|
||||
trackingLabel: "typescript",
|
||||
},
|
||||
{
|
||||
name: "React",
|
||||
href: "https://portal.thirdweb.com/react/v5",
|
||||
icon: ReactIcon,
|
||||
trackingLabel: "react",
|
||||
},
|
||||
{
|
||||
name: "React Native",
|
||||
href: "https://portal.thirdweb.com/react-native/v5",
|
||||
icon: ReactIcon,
|
||||
trackingLabel: "react_native",
|
||||
},
|
||||
{
|
||||
name: "Unity",
|
||||
href: "https://portal.thirdweb.com/unity/v5",
|
||||
icon: UnityIcon,
|
||||
trackingLabel: "unity",
|
||||
},
|
||||
{
|
||||
name: "Unreal Engine",
|
||||
href: "https://portal.thirdweb.com/unreal-engine",
|
||||
icon: UnrealIcon,
|
||||
trackingLabel: "unreal",
|
||||
},
|
||||
{
|
||||
name: ".NET",
|
||||
href: "https://portal.thirdweb.com/dotnet",
|
||||
icon: DotNetIcon,
|
||||
trackingLabel: "dotnet",
|
||||
},
|
||||
];
|
||||
|
||||
function SDKSection() {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 font-semibold text-xl tracking-tight">Client SDKs</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{sdks.map((sdk) => (
|
||||
<SDKCard
|
||||
key={sdk.name}
|
||||
name={sdk.name}
|
||||
href={sdk.href}
|
||||
icon={sdk.icon}
|
||||
trackingLabel={sdk.trackingLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SDKCard(props: SDKCardProps) {
|
||||
return (
|
||||
<div className="relative flex items-center gap-3 rounded-lg border bg-card p-4 hover:border-active-border">
|
||||
<div className="flex size-9 items-center justify-center rounded-full border">
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-medium text-base">
|
||||
<TrackedLinkTW
|
||||
href={props.href}
|
||||
className="before:absolute before:inset-0"
|
||||
target="_blank"
|
||||
category="project-ftux"
|
||||
label={props.trackingLabel}
|
||||
>
|
||||
{props.name}
|
||||
</TrackedLinkTW>
|
||||
</p>
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
View Docs
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// starter kits section ------------------------------------------------------------
|
||||
|
||||
type StartedKitCardProps = {
|
||||
name: string;
|
||||
href: string;
|
||||
trackingLabel: string;
|
||||
};
|
||||
|
||||
const startedKits: StartedKitCardProps[] = [
|
||||
{
|
||||
name: "Next Starter",
|
||||
href: "https://github.com/thirdweb-example/next-starter",
|
||||
trackingLabel: "next_starter",
|
||||
},
|
||||
{
|
||||
name: "Vite Starter",
|
||||
href: "https://github.com/thirdweb-example/vite-starter",
|
||||
trackingLabel: "vite_starter",
|
||||
},
|
||||
{
|
||||
name: "Expo Starter",
|
||||
href: "https://github.com/thirdweb-example/expo-starter",
|
||||
trackingLabel: "expo_starter",
|
||||
},
|
||||
{
|
||||
name: "Node Starter",
|
||||
href: "https://github.com/thirdweb-example/node-starter",
|
||||
trackingLabel: "node_starter",
|
||||
},
|
||||
];
|
||||
|
||||
function StarterKitsSection() {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Starter Kits</h2>
|
||||
<TrackedLinkTW
|
||||
href="https://github.com/thirdweb-example"
|
||||
target="_blank"
|
||||
category="project-ftux"
|
||||
label="view_all_templates"
|
||||
className="inline-flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
View all <ChevronRightIcon className="size-3" />
|
||||
</TrackedLinkTW>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{startedKits.map((kit) => (
|
||||
<StarterKitCard
|
||||
key={kit.name}
|
||||
name={kit.name}
|
||||
href={kit.href}
|
||||
trackingLabel={kit.trackingLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StarterKitCard(props: StartedKitCardProps) {
|
||||
return (
|
||||
<div className="relative flex items-center gap-3 rounded-lg border bg-card p-4 hover:border-active-border">
|
||||
<div className="flex size-9 items-center justify-center rounded-full border">
|
||||
<GithubIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<TrackedLinkTW
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
category="project-ftux"
|
||||
label={props.trackingLabel}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
{props.name}
|
||||
</TrackedLinkTW>
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
View Repo
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { rotateSecretKeyClient } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useState } from "react";
|
||||
import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage";
|
||||
|
||||
export function SecretKeySection(props: {
|
||||
secretKeyMasked: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Secret Key</h3>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
Identifies and authenticates your application from a backend. <br />{" "}
|
||||
This is not the full secret key, Refer to your saved secret key at the
|
||||
time of creation for the full secret key.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm lg:w-[350px]">
|
||||
{secretKeyMasked}
|
||||
</div>
|
||||
|
||||
<RotateSecretKeyButton
|
||||
rotateSecretKey={async () => {
|
||||
return rotateSecretKeyClient(props.projectId);
|
||||
}}
|
||||
onSuccess={(data) => {
|
||||
setSecretKeyMasked(data.data.secretMasked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getSMSCountryTiers } from "@/api/sms";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { redirect } from "next/navigation";
|
||||
import { InAppWalletSettingsPage } from "../../../../../../../components/embedded-wallets/Configure";
|
||||
@@ -9,9 +10,10 @@ export default async function Page(props: {
|
||||
}) {
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
|
||||
const [team, project] = await Promise.all([
|
||||
const [team, project, smsCountryTiers] = await Promise.all([
|
||||
getTeamBySlug(team_slug),
|
||||
getProject(team_slug, project_slug),
|
||||
getSMSCountryTiers(),
|
||||
]);
|
||||
|
||||
if (!team) {
|
||||
@@ -29,6 +31,7 @@ export default async function Page(props: {
|
||||
trackingCategory="in-app-wallet-project-settings"
|
||||
teamSlug={team_slug}
|
||||
validTeamPlan={getValidTeamPlan(team)}
|
||||
smsCountryTiers={smsCountryTiers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-4
@@ -43,25 +43,21 @@ function Story() {
|
||||
name: "60 Days",
|
||||
onClick: () => setTab("60-day"),
|
||||
isActive: tab === "60-day",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "30 Days",
|
||||
onClick: () => setTab("30-day"),
|
||||
isActive: tab === "30-day",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "7 Days",
|
||||
onClick: () => setTab("7-day"),
|
||||
isActive: tab === "7-day",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "Pending",
|
||||
onClick: () => setTab("pending"),
|
||||
isActive: tab === "pending",
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
|
||||
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader";
|
||||
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard";
|
||||
import { EmptyState } from "../../components/Analytics/EmptyState";
|
||||
import { PieChartCard } from "../../components/Analytics/PieChartCard";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsCharts } from "./components/Transactions";
|
||||
|
||||
@@ -85,11 +85,13 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
title={project.name}
|
||||
interval={interval}
|
||||
range={range}
|
||||
showRangeSelector={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isActive ? (
|
||||
<div className="container p-6">
|
||||
<EmptyState />
|
||||
<div className="container py-8 pb-20">
|
||||
<ProjectFTUX project={project} teamSlug={params.team_slug} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="container flex grow flex-col py-6">
|
||||
|
||||
+8
-13
@@ -406,7 +406,6 @@ function ProjectNameSetting(props: {
|
||||
handleSubmit: () => void;
|
||||
}) {
|
||||
const { form, handleSubmit } = props;
|
||||
const isNameDirty = form.getFieldState("name").isDirty;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
@@ -419,8 +418,8 @@ function ProjectNameSetting(props: {
|
||||
errorText={form.getFieldState("name").error?.message}
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !isNameDirty,
|
||||
isPending: props.isUpdatingProject && isNameDirty,
|
||||
disabled: false,
|
||||
isPending: props.isUpdatingProject,
|
||||
}}
|
||||
bottomText="Please use 64 characters at maximum"
|
||||
>
|
||||
@@ -501,7 +500,6 @@ function AllowedDomainsSetting(props: {
|
||||
handleSubmit: () => void;
|
||||
}) {
|
||||
const { form, handleSubmit } = props;
|
||||
const isDomainsDirty = form.getFieldState("domains").isDirty;
|
||||
|
||||
const helperText = (
|
||||
<ul className="flex list-disc flex-col gap-1.5 py-1 pl-3 text-muted-foreground text-sm [&>li]:pl-1">
|
||||
@@ -544,8 +542,8 @@ function AllowedDomainsSetting(props: {
|
||||
errorText={form.getFieldState("domains", form.formState).error?.message}
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !isDomainsDirty,
|
||||
isPending: props.isUpdatingProject && isDomainsDirty,
|
||||
disabled: false,
|
||||
isPending: props.isUpdatingProject,
|
||||
}}
|
||||
bottomText="This is only applicable for web applications"
|
||||
>
|
||||
@@ -606,13 +604,12 @@ function AllowedBundleIDsSetting(props: {
|
||||
handleSubmit: () => void;
|
||||
}) {
|
||||
const { form, handleSubmit } = props;
|
||||
const isBundleIdsDirty = form.getFieldState("bundleIds").isDirty;
|
||||
return (
|
||||
<SettingsCard
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !isBundleIdsDirty,
|
||||
isPending: props.isUpdatingProject && isBundleIdsDirty,
|
||||
disabled: false,
|
||||
isPending: props.isUpdatingProject,
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
header={{
|
||||
@@ -715,7 +712,7 @@ function EnabledServicesSetting(props: {
|
||||
errorText={undefined}
|
||||
saveButton={{
|
||||
onClick: handleSubmit,
|
||||
disabled: !form.formState.isDirty,
|
||||
disabled: false,
|
||||
isPending: props.isUpdatingProject,
|
||||
}}
|
||||
bottomText=""
|
||||
@@ -984,7 +981,7 @@ function DeleteProject(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function RotateSecretKeyButton(props: {
|
||||
export function RotateSecretKeyButton(props: {
|
||||
rotateSecretKey: RotateSecretKey;
|
||||
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
|
||||
}) {
|
||||
@@ -1219,8 +1216,6 @@ function TransferProject(props: {
|
||||
selectedTeamId === props.currentTeamId ||
|
||||
!props.isOwnerAccount;
|
||||
|
||||
console.log({ selectedTeam: selectedTeamWithRole, isDisabled });
|
||||
|
||||
const handleTransfer = () => {
|
||||
if (!hasOwnerRole) {
|
||||
return;
|
||||
|
||||
@@ -39,6 +39,7 @@ function Component() {
|
||||
title="Project Overview"
|
||||
interval="day"
|
||||
range={getLastNDaysRange("last-120")}
|
||||
showRangeSelector={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,9 @@ export function AnalyticsHeader(props: {
|
||||
title: string;
|
||||
interval: "day" | "week";
|
||||
range: Range;
|
||||
showRangeSelector: boolean;
|
||||
}) {
|
||||
const { title, interval, range } = props;
|
||||
const { title, interval, range, showRangeSelector } = props;
|
||||
|
||||
return (
|
||||
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
|
||||
@@ -15,7 +16,7 @@ export function AnalyticsHeader(props: {
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<RangeSelector interval={interval} range={range} />
|
||||
{showRangeSelector && <RangeSelector interval={interval} range={range} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,6 @@ export function NotificationButtonInner(props: {
|
||||
/>
|
||||
),
|
||||
onClick: () => setTab("inbox"),
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
isActive: tab === "changelogs",
|
||||
@@ -164,7 +163,6 @@ export function NotificationButtonInner(props: {
|
||||
/>
|
||||
),
|
||||
onClick: () => setTab("changelogs"),
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -63,6 +63,7 @@ const GAS_FREE_CHAINS = [
|
||||
247253, // Saakuru Testnet
|
||||
19011, // Homeverse Mainnet
|
||||
40875, // Homeverse Testnet
|
||||
5464, // Saga Mainnet
|
||||
];
|
||||
|
||||
function useIsNetworkMismatch(txChainId: number) {
|
||||
|
||||
+8
@@ -64,6 +64,14 @@ function Variants(props: {
|
||||
isUpdating={false}
|
||||
trackingCategory="foo"
|
||||
updateApiKey={() => {}}
|
||||
smsCountryTiers={{
|
||||
// scaffold some countries to play around with the UI
|
||||
tier1: ["US", "CA"],
|
||||
tier2: ["GB", "AU", "NZ"],
|
||||
tier3: ["FR", "DE", "ES", "IT"],
|
||||
tier4: ["JP", "KR", "MX", "RU"],
|
||||
tier5: ["BR", "AR", "CO", "CL", "PE", "VE", "SA"],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { SMSCountryTiers } from "@/api/sms";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
@@ -36,6 +37,7 @@ import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toArrFromList } from "utils/string";
|
||||
import type { Team } from "../../../@/api/team";
|
||||
import CountrySelector from "./sms-country-select/country-selector";
|
||||
|
||||
type InAppWalletSettingsPageProps = {
|
||||
trackingCategory: string;
|
||||
@@ -43,6 +45,7 @@ type InAppWalletSettingsPageProps = {
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
validTeamPlan: Team["billingPlan"];
|
||||
smsCountryTiers: SMSCountryTiers;
|
||||
};
|
||||
|
||||
const TRACKING_CATEGORY = "embedded-wallet";
|
||||
@@ -108,6 +111,7 @@ export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) {
|
||||
canEditAdvancedFeatures={props.validTeamPlan !== "free"}
|
||||
updateApiKey={handleUpdateProject}
|
||||
isUpdating={updateProject.isPending}
|
||||
smsCountryTiers={props.smsCountryTiers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +124,7 @@ const InAppWalletSettingsPageUI: React.FC<
|
||||
trackingData: UpdateAPIKeyTrackingData,
|
||||
) => void;
|
||||
isUpdating: boolean;
|
||||
smsCountryTiers: SMSCountryTiers;
|
||||
}
|
||||
> = (props) => {
|
||||
const embeddedWalletService = props.project.services.find(
|
||||
@@ -185,6 +190,11 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
}
|
||||
: undefined),
|
||||
redirectUrls: (config.redirectUrls || []).join("\n"),
|
||||
smsEnabledCountryISOs: config.smsEnabledCountryISOs
|
||||
? config.smsEnabledCountryISOs
|
||||
: canEditAdvancedFeatures
|
||||
? ["US", "CA"]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -228,6 +238,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
applicationImageUrl: branding?.applicationImageUrl,
|
||||
applicationName: branding?.applicationName || props.project.name,
|
||||
redirectUrls: toArrFromList(redirectUrls || "", true),
|
||||
smsEnabledCountryISOs: values.smsEnabledCountryISOs,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -256,6 +267,8 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
canEditAdvancedFeatures={canEditAdvancedFeatures}
|
||||
/>
|
||||
|
||||
<NativeAppsFieldset form={form} />
|
||||
|
||||
{/* Authentication */}
|
||||
<Fieldset legend="Authentication">
|
||||
<JSONWebTokenFields
|
||||
@@ -269,9 +282,15 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
form={form}
|
||||
canEditAdvancedFeatures={canEditAdvancedFeatures}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<NativeAppsFieldset form={form} />
|
||||
<div className="h-5" />
|
||||
|
||||
<SMSCountryFields
|
||||
form={form}
|
||||
canEditAdvancedFeatures={canEditAdvancedFeatures}
|
||||
smsCountryTiers={props.smsCountryTiers}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary" className="gap-2">
|
||||
@@ -364,6 +383,61 @@ function BrandingFieldset(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function SMSCountryFields(props: {
|
||||
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
|
||||
canEditAdvancedFeatures: boolean;
|
||||
smsCountryTiers: SMSCountryTiers;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SwitchContainer
|
||||
switchId="sms-switch"
|
||||
title="SMS"
|
||||
description="Optionally allow users in selected countries to login via SMS OTP."
|
||||
>
|
||||
<GatedSwitch
|
||||
id="sms-switch"
|
||||
trackingLabel="sms"
|
||||
checked={
|
||||
!!props.form.watch("smsEnabledCountryISOs").length &&
|
||||
props.canEditAdvancedFeatures
|
||||
}
|
||||
upgradeRequired={!props.canEditAdvancedFeatures}
|
||||
onCheckedChange={(checked) =>
|
||||
props.form.setValue(
|
||||
"smsEnabledCountryISOs",
|
||||
checked
|
||||
? // by default, enable US and CA only
|
||||
["US", "CA"]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SwitchContainer>
|
||||
|
||||
<AdvancedConfigurationContainer
|
||||
className="grid grid-cols-1"
|
||||
show={
|
||||
props.canEditAdvancedFeatures &&
|
||||
!!props.form.watch("smsEnabledCountryISOs").length
|
||||
}
|
||||
>
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="smsEnabledCountryISOs"
|
||||
render={({ field }) => (
|
||||
<CountrySelector
|
||||
countryTiers={props.smsCountryTiers}
|
||||
selected={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</AdvancedConfigurationContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JSONWebTokenFields(props: {
|
||||
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
|
||||
canEditAdvancedFeatures: boolean;
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import type { SMSCountryTiers } from "@/api/sms";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||
import {
|
||||
countryNames,
|
||||
countryPrefixes,
|
||||
getCountryFlag,
|
||||
tierPricing,
|
||||
} from "./utils";
|
||||
|
||||
interface CountrySelectorProps {
|
||||
countryTiers: SMSCountryTiers;
|
||||
selected: string[];
|
||||
onChange: (selectedCountries: string[]) => void;
|
||||
}
|
||||
|
||||
export default function CountrySelector({
|
||||
countryTiers,
|
||||
selected,
|
||||
onChange,
|
||||
}: CountrySelectorProps) {
|
||||
// Helper function to check if a country is selected
|
||||
const isCountrySelected = (country: string) => selected.includes(country);
|
||||
|
||||
// Check if all countries in a tier are selected
|
||||
const isTierSelected = (tier: string) => {
|
||||
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
|
||||
return tierCountries.every((country) => isCountrySelected(country));
|
||||
};
|
||||
|
||||
// Check if some countries in a tier are selected
|
||||
const isTierIndeterminate = (tier: string) => {
|
||||
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
|
||||
const selectedInTier = tierCountries.filter((country) =>
|
||||
isCountrySelected(country),
|
||||
);
|
||||
return (
|
||||
selectedInTier.length > 0 && selectedInTier.length < tierCountries.length
|
||||
);
|
||||
};
|
||||
|
||||
// Toggle a tier selection
|
||||
const toggleTier = (tier: string) => {
|
||||
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
|
||||
let newSelected: string[];
|
||||
|
||||
if (isTierSelected(tier)) {
|
||||
// Deselect all countries in this tier
|
||||
newSelected = selected.filter(
|
||||
(country) => !tierCountries.includes(country),
|
||||
);
|
||||
} else {
|
||||
// Select all countries in this tier
|
||||
const currentSelected = new Set(selected);
|
||||
for (const country of tierCountries) {
|
||||
currentSelected.add(country);
|
||||
}
|
||||
newSelected = Array.from(currentSelected);
|
||||
}
|
||||
|
||||
// Call onChange with the updated selection
|
||||
onChange(newSelected);
|
||||
};
|
||||
|
||||
// Toggle a single country selection
|
||||
const toggleCountry = (country: string) => {
|
||||
let newSelected: string[];
|
||||
|
||||
if (isCountrySelected(country)) {
|
||||
// Remove country from selection
|
||||
newSelected = selected.filter((c) => c !== country);
|
||||
} else {
|
||||
// Add country to selection
|
||||
newSelected = [...selected, country];
|
||||
}
|
||||
|
||||
// Call onChange with the updated selection
|
||||
onChange(newSelected);
|
||||
};
|
||||
|
||||
// Get selected countries count for a tier
|
||||
const getSelectedCountInTier = (tier: string) => {
|
||||
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
|
||||
return tierCountries.filter((country) => isCountrySelected(country)).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(countryTiers).map(([tier, countries], index) => (
|
||||
<div key={tier} className="overflow-hidden rounded-md border">
|
||||
<div className="flex items-center bg-muted/30 p-2">
|
||||
<div className="relative mr-2 flex items-center">
|
||||
<Checkbox
|
||||
id={`tier-${index + 1}`}
|
||||
checked={isTierSelected(tier)}
|
||||
onCheckedChange={() => toggleTier(tier)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{isTierIndeterminate(tier) && (
|
||||
<MinusIcon className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-1/2 h-2.5 w-2.5 transform text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor={`tier-${index + 1}`}
|
||||
className="flex-1 cursor-pointer font-medium text-sm"
|
||||
>
|
||||
Tier {index + 1}
|
||||
{isTierIndeterminate(tier) && (
|
||||
<span className="ml-1 font-normal text-muted-foreground text-xs">
|
||||
({getSelectedCountInTier(tier)}/{countries.length})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-xs",
|
||||
tier === "tier1"
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tierPricing[tier as keyof typeof tierPricing]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-1.5 p-2",
|
||||
countries.length === 2
|
||||
? "md:grid-cols-2"
|
||||
: countries.length === 3
|
||||
? "md:grid-cols-3"
|
||||
: "md:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{countries.map((country) => (
|
||||
<div
|
||||
key={country}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center justify-between rounded-md border p-1.5 transition-colors ",
|
||||
isCountrySelected(country)
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-transparent hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => toggleCountry(country)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
toggleCountry(country);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={countryNames[country] || country}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
<span className="text-lg" aria-hidden="true">
|
||||
{getCountryFlag(country)}
|
||||
</span>
|
||||
<span className="font-medium text-xs">
|
||||
{countryNames[country] || country}
|
||||
{countryPrefixes[country]
|
||||
? ` (${countryPrefixes[country]})`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex h-3 w-3 items-center justify-center">
|
||||
{isCountrySelected(country) && (
|
||||
<CheckIcon className="h-3 w-3 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// Pricing for each tier
|
||||
export const tierPricing = {
|
||||
tier1: "Included",
|
||||
tier2: "$0.05 per SMS",
|
||||
tier3: "$0.10 per SMS",
|
||||
tier4: "$0.20 per SMS",
|
||||
tier5: "$0.40 per SMS",
|
||||
} as const;
|
||||
|
||||
// Country names mapped to ISO codes
|
||||
export const countryNames: Record<string, string> = {
|
||||
// Tier 1
|
||||
CA: "Canada",
|
||||
US: "United States",
|
||||
|
||||
// Tier 2
|
||||
BH: "Bahrain",
|
||||
CN: "China",
|
||||
CR: "Costa Rica",
|
||||
GB: "United Kingdom",
|
||||
GL: "Greenland",
|
||||
KR: "Korea Republic of",
|
||||
LI: "Liechtenstein",
|
||||
NF: "Norfolk Island",
|
||||
PL: "Poland",
|
||||
PR: "Puerto Rico",
|
||||
PT: "Portugal",
|
||||
SG: "Singapore",
|
||||
TH: "Thailand",
|
||||
TR: "Turkey",
|
||||
VI: "Virgin Islands, U.S.",
|
||||
|
||||
// Tier 3
|
||||
AR: "Argentina",
|
||||
AT: "Austria",
|
||||
AU: "Australia",
|
||||
BA: "Bosnia and Herzegovina",
|
||||
BN: "Brunei",
|
||||
BR: "Brazil",
|
||||
BS: "Bahamas",
|
||||
BW: "Botswana",
|
||||
CH: "Switzerland",
|
||||
CL: "Chile",
|
||||
CO: "Colombia",
|
||||
CU: "Cuba",
|
||||
CY: "Cyprus",
|
||||
CZ: "Czech Republic",
|
||||
DE: "Germany",
|
||||
DK: "Denmark",
|
||||
EE: "Estonia",
|
||||
ES: "Spain",
|
||||
FI: "Finland",
|
||||
FM: "Micronesia",
|
||||
FO: "Faroe Islands",
|
||||
FR: "France",
|
||||
GR: "Greece",
|
||||
GU: "Guam",
|
||||
HK: "Hong Kong",
|
||||
HU: "Hungary",
|
||||
IE: "Ireland",
|
||||
IN: "India",
|
||||
IS: "Iceland",
|
||||
IT: "Italy",
|
||||
JP: "Japan",
|
||||
LT: "Lithuania",
|
||||
LU: "Luxembourg",
|
||||
LV: "Latvia",
|
||||
MD: "Moldova",
|
||||
MK: "Macedonia",
|
||||
MO: "Macao",
|
||||
MT: "Malta",
|
||||
NA: "Namibia",
|
||||
NO: "Norway",
|
||||
PW: "Palau",
|
||||
RO: "Romania",
|
||||
SB: "Solomon Islands",
|
||||
SE: "Sweden",
|
||||
SK: "Slovakia",
|
||||
TW: "Taiwan",
|
||||
UY: "Uruguay",
|
||||
ZA: "South Africa",
|
||||
|
||||
// Tier 4
|
||||
AD: "Andorra",
|
||||
AE: "United Arab Emirates",
|
||||
AG: "Antigua and Barbuda",
|
||||
AI: "Anguilla",
|
||||
AL: "Albania",
|
||||
AM: "Armenia",
|
||||
AO: "Angola",
|
||||
AS: "American Samoa",
|
||||
AW: "Aruba",
|
||||
BB: "Barbados",
|
||||
BE: "Belgium",
|
||||
BG: "Bulgaria",
|
||||
CK: "Cook Islands",
|
||||
CM: "Cameroon",
|
||||
DJ: "Djibouti",
|
||||
DO: "Dominican Republic",
|
||||
ER: "Eritrea",
|
||||
FJ: "Fiji",
|
||||
FK: "Falkland Islands",
|
||||
GE: "Georgia",
|
||||
GF: "French Guiana",
|
||||
GI: "Gibraltar",
|
||||
GM: "Gambia",
|
||||
GN: "Guinea",
|
||||
GP: "Guadeloupe",
|
||||
GQ: "Equatorial Guinea",
|
||||
HR: "Croatia",
|
||||
KI: "Kiribati",
|
||||
KN: "St Kitts and Nevis",
|
||||
LR: "Liberia",
|
||||
MC: "Monaco",
|
||||
ME: "Montenegro",
|
||||
MH: "Marshall Islands",
|
||||
MQ: "Martinique",
|
||||
MS: "Montserrat",
|
||||
MU: "Mauritius",
|
||||
MX: "Mexico",
|
||||
NC: "New Caledonia",
|
||||
NI: "Nicaragua",
|
||||
NL: "Netherlands",
|
||||
NU: "Niue",
|
||||
NZ: "New Zealand",
|
||||
OM: "Oman",
|
||||
PA: "Panama",
|
||||
PF: "French Polynesia",
|
||||
PM: "St Pierre and Miquelon",
|
||||
PY: "Paraguay",
|
||||
RE: "Reunion/Mayotte",
|
||||
SA: "Saudi Arabia",
|
||||
SI: "Slovenia",
|
||||
SL: "Sierra Leone",
|
||||
SM: "San Marino",
|
||||
SR: "Suriname",
|
||||
ST: "Sao Tome and Principe",
|
||||
SV: "El Salvador",
|
||||
TC: "Turks and Caicos Islands",
|
||||
TO: "Tonga",
|
||||
TV: "Tuvalu",
|
||||
UA: "Ukraine",
|
||||
VE: "Venezuela",
|
||||
VN: "Vietnam",
|
||||
WF: "Wallis and Futuna",
|
||||
|
||||
// Tier 5
|
||||
AF: "Afghanistan",
|
||||
AZ: "Azerbaijan",
|
||||
BD: "Bangladesh",
|
||||
BF: "Burkina Faso",
|
||||
BI: "Burundi",
|
||||
BJ: "Benin",
|
||||
BM: "Bermuda",
|
||||
BO: "Bolivia",
|
||||
BY: "Belarus",
|
||||
BZ: "Belize",
|
||||
CD: "DR Congo",
|
||||
CG: "Congo",
|
||||
CI: "Ivory Coast",
|
||||
CV: "Cape Verde",
|
||||
DM: "Dominica",
|
||||
DZ: "Algeria",
|
||||
EC: "Ecuador",
|
||||
EG: "Egypt",
|
||||
ET: "Ethiopia",
|
||||
GA: "Gabon",
|
||||
GD: "Grenada",
|
||||
GH: "Ghana",
|
||||
GT: "Guatemala",
|
||||
GW: "Guinea-Bissau",
|
||||
GY: "Guyana",
|
||||
HN: "Honduras",
|
||||
HT: "Haiti",
|
||||
IL: "Israel",
|
||||
IQ: "Iraq",
|
||||
JM: "Jamaica",
|
||||
JO: "Jordan",
|
||||
KE: "Kenya",
|
||||
KG: "Kyrgyzstan",
|
||||
KH: "Cambodia",
|
||||
KW: "Kuwait",
|
||||
KY: "Cayman Islands",
|
||||
KZ: "Kazakhstan",
|
||||
LA: "Laos PDR",
|
||||
LB: "Lebanon",
|
||||
LC: "St Lucia",
|
||||
LK: "Sri Lanka",
|
||||
LS: "Lesotho",
|
||||
LY: "Libya",
|
||||
MA: "Morocco",
|
||||
MG: "Madagascar",
|
||||
ML: "Mali",
|
||||
MM: "Myanmar",
|
||||
MN: "Mongolia",
|
||||
MR: "Mauritania",
|
||||
MV: "Maldives",
|
||||
MW: "Malawi",
|
||||
MY: "Malaysia",
|
||||
MZ: "Mozambique",
|
||||
NE: "Niger",
|
||||
NG: "Nigeria",
|
||||
NP: "Nepal",
|
||||
PE: "Peru",
|
||||
PH: "Philippines",
|
||||
PK: "Pakistan",
|
||||
PS: "Palestinian Territory",
|
||||
QA: "Qatar",
|
||||
RS: "Serbia",
|
||||
RW: "Rwanda",
|
||||
SC: "Seychelles",
|
||||
SD: "Sudan",
|
||||
SN: "Senegal",
|
||||
SO: "Somalia",
|
||||
SS: "South Sudan",
|
||||
SY: "Syria",
|
||||
SZ: "Swaziland",
|
||||
TD: "Chad",
|
||||
TG: "Togo",
|
||||
TL: "East Timor",
|
||||
TM: "Turkmenistan",
|
||||
TN: "Tunisia",
|
||||
TT: "Trinidad and Tobago",
|
||||
UG: "Uganda",
|
||||
VC: "St Vincent Grenadines",
|
||||
VG: "Virgin Islands, British",
|
||||
VU: "Vanuatu",
|
||||
WS: "Samoa",
|
||||
YE: "Yemen",
|
||||
ZM: "Zambia",
|
||||
ZW: "Zimbabwe",
|
||||
} as const;
|
||||
|
||||
export const countryPrefixes: Record<string, string> = {
|
||||
// Tier 1
|
||||
CA: "+1",
|
||||
US: "+1",
|
||||
|
||||
// Tier 2
|
||||
BH: "+973",
|
||||
CN: "+86",
|
||||
CR: "+506",
|
||||
GB: "+44",
|
||||
GL: "+299",
|
||||
KR: "+82",
|
||||
LI: "+423",
|
||||
NF: "+672",
|
||||
PL: "+48",
|
||||
PR: "+1",
|
||||
PT: "+351",
|
||||
SG: "+65",
|
||||
TH: "+66",
|
||||
TR: "+90",
|
||||
VI: "+1",
|
||||
|
||||
// Tier 3
|
||||
AR: "+54",
|
||||
AT: "+43",
|
||||
AU: "+61",
|
||||
BA: "+387",
|
||||
BN: "+673",
|
||||
BR: "+55",
|
||||
BS: "+1",
|
||||
BW: "+267",
|
||||
CH: "+41",
|
||||
CL: "+56",
|
||||
CO: "+57",
|
||||
CU: "+53",
|
||||
CY: "+357",
|
||||
CZ: "+420",
|
||||
DE: "+49",
|
||||
DK: "+45",
|
||||
EE: "+372",
|
||||
ES: "+34",
|
||||
FI: "+358",
|
||||
FM: "+691",
|
||||
FO: "+298",
|
||||
FR: "+33",
|
||||
GR: "+30",
|
||||
GU: "+1",
|
||||
HK: "+852",
|
||||
HU: "+36",
|
||||
IE: "+353",
|
||||
IN: "+91",
|
||||
IS: "+354",
|
||||
IT: "+39",
|
||||
JP: "+81",
|
||||
LT: "+370",
|
||||
LU: "+352",
|
||||
LV: "+371",
|
||||
MD: "+373",
|
||||
MK: "+389",
|
||||
MO: "+853",
|
||||
MT: "+356",
|
||||
NA: "+264",
|
||||
NO: "+47",
|
||||
PW: "+680",
|
||||
RO: "+40",
|
||||
SB: "+677",
|
||||
SE: "+46",
|
||||
SK: "+421",
|
||||
TW: "+886",
|
||||
UY: "+598",
|
||||
ZA: "+27",
|
||||
|
||||
// Tier 4
|
||||
AD: "+376",
|
||||
AE: "+971",
|
||||
AG: "+1",
|
||||
AI: "+1",
|
||||
AL: "+355",
|
||||
AM: "+374",
|
||||
AO: "+244",
|
||||
AS: "+1",
|
||||
AW: "+297",
|
||||
BB: "+1",
|
||||
BE: "+32",
|
||||
BG: "+359",
|
||||
CK: "+682",
|
||||
CM: "+237",
|
||||
DJ: "+253",
|
||||
DO: "+1",
|
||||
ER: "+291",
|
||||
FJ: "+679",
|
||||
FK: "+500",
|
||||
GE: "+995",
|
||||
GF: "+594",
|
||||
GI: "+350",
|
||||
GM: "+220",
|
||||
GN: "+224",
|
||||
GP: "+590",
|
||||
GQ: "+240",
|
||||
HR: "+385",
|
||||
KI: "+686",
|
||||
KN: "+1",
|
||||
LR: "+231",
|
||||
MC: "+377",
|
||||
ME: "+382",
|
||||
MH: "+692",
|
||||
MQ: "+596",
|
||||
MS: "+1",
|
||||
MU: "+230",
|
||||
MX: "+52",
|
||||
NC: "+687",
|
||||
NI: "+505",
|
||||
NL: "+31",
|
||||
NU: "+683",
|
||||
NZ: "+64",
|
||||
OM: "+968",
|
||||
PA: "+507",
|
||||
PF: "+689",
|
||||
PM: "+508",
|
||||
PY: "+595",
|
||||
RE: "+262",
|
||||
SA: "+966",
|
||||
SI: "+386",
|
||||
SL: "+232",
|
||||
SM: "+378",
|
||||
SR: "+597",
|
||||
ST: "+239",
|
||||
SV: "+503",
|
||||
TC: "+1",
|
||||
TO: "+676",
|
||||
TV: "+688",
|
||||
UA: "+380",
|
||||
VE: "+58",
|
||||
VN: "+84",
|
||||
WF: "+681",
|
||||
|
||||
// Tier 5
|
||||
AF: "+93",
|
||||
AZ: "+994",
|
||||
BD: "+880",
|
||||
BF: "+226",
|
||||
BI: "+257",
|
||||
BJ: "+229",
|
||||
BM: "+1",
|
||||
BO: "+591",
|
||||
BY: "+375",
|
||||
BZ: "+501",
|
||||
CD: "+243",
|
||||
CG: "+242",
|
||||
CI: "+225",
|
||||
CV: "+238",
|
||||
DM: "+1",
|
||||
DZ: "+213",
|
||||
EC: "+593",
|
||||
EG: "+20",
|
||||
ET: "+251",
|
||||
GA: "+241",
|
||||
GD: "+1",
|
||||
GH: "+233",
|
||||
GT: "+502",
|
||||
GW: "+245",
|
||||
GY: "+592",
|
||||
HN: "+504",
|
||||
HT: "+509",
|
||||
IL: "+972",
|
||||
IQ: "+964",
|
||||
JM: "+1",
|
||||
JO: "+962",
|
||||
KE: "+254",
|
||||
KG: "+996",
|
||||
KH: "+855",
|
||||
KW: "+965",
|
||||
KY: "+1",
|
||||
KZ: "+7",
|
||||
LA: "+856",
|
||||
LB: "+961",
|
||||
LC: "+1",
|
||||
LK: "+94",
|
||||
LS: "+266",
|
||||
LY: "+218",
|
||||
MA: "+212",
|
||||
MG: "+261",
|
||||
ML: "+223",
|
||||
MM: "+95",
|
||||
MN: "+976",
|
||||
MR: "+222",
|
||||
MV: "+960",
|
||||
MW: "+265",
|
||||
MY: "+60",
|
||||
MZ: "+258",
|
||||
NE: "+227",
|
||||
NG: "+234",
|
||||
NP: "+977",
|
||||
PE: "+51",
|
||||
PH: "+63",
|
||||
PK: "+92",
|
||||
PS: "+970",
|
||||
QA: "+974",
|
||||
RS: "+381",
|
||||
RW: "+250",
|
||||
SC: "+248",
|
||||
SD: "+249",
|
||||
SN: "+221",
|
||||
SO: "+252",
|
||||
SS: "+211",
|
||||
SY: "+963",
|
||||
SZ: "+268",
|
||||
TD: "+235",
|
||||
TG: "+228",
|
||||
TL: "+670",
|
||||
TM: "+993",
|
||||
TN: "+216",
|
||||
TT: "+1",
|
||||
UG: "+256",
|
||||
VC: "+1",
|
||||
VG: "+1",
|
||||
VU: "+678",
|
||||
WS: "+685",
|
||||
YE: "+967",
|
||||
ZM: "+260",
|
||||
ZW: "+263",
|
||||
} as const;
|
||||
|
||||
// Helper function to convert country code to flag emoji
|
||||
export const getCountryFlag = (countryCode: string) => {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split("")
|
||||
.map((char) => 127397 + char.charCodeAt(0));
|
||||
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
@@ -109,6 +109,7 @@ export const apiKeyEmbeddedWalletsValidationSchema = z.object({
|
||||
}),
|
||||
]),
|
||||
redirectUrls: redirectUriSchema,
|
||||
smsEnabledCountryISOs: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const apiKeyPayConfigValidationSchema = z.object({
|
||||
|
||||
@@ -55,6 +55,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
"relayer",
|
||||
"chainsaw",
|
||||
],
|
||||
stripeCustomerId: "cus_1234567890",
|
||||
};
|
||||
|
||||
return team;
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
"@radix-ui/react-tooltip": "1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "6.0.8",
|
||||
"lucide-react": "0.477.0",
|
||||
"next": "15.2.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"jose": "6.0.10",
|
||||
"lucide-react": "0.483.0",
|
||||
"next": "15.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
@@ -37,11 +37,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.0",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/node": "22.13.10",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"eslint": "9.21.0",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"eslint": "9.22.0",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.2"
|
||||
|
||||
@@ -31,17 +31,17 @@
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "1.1.8",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"@tanstack/react-query": "5.67.3",
|
||||
"@thirdweb-dev/engine": "^0.0.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "6.0.8",
|
||||
"lucide-react": "0.477.0",
|
||||
"next": "15.2.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"jose": "6.0.10",
|
||||
"lucide-react": "0.483.0",
|
||||
"next": "15.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "^12.1.3",
|
||||
"prettier": "3.3.3",
|
||||
"prettier": "3.5.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
@@ -55,11 +55,11 @@
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.13.9",
|
||||
"@types/node": "22.13.10",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { CodeLoading } from "../../../../components/code/code.client";
|
||||
import type { PayEmbedPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(
|
||||
() => import("../../../../components/code/code.client"),
|
||||
);
|
||||
|
||||
export function CodeGen(props: {
|
||||
options: PayEmbedPlaygroundOptions;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full grow flex-col">
|
||||
<Suspense fallback={<CodeLoading />}>
|
||||
<CodeClient
|
||||
code={getCode(props.options)}
|
||||
lang="tsx"
|
||||
loader={<CodeLoading />}
|
||||
// Need to add max-h in both places - TODO figure out a better way
|
||||
className="xl:h-[calc(100vh-100px)]"
|
||||
scrollableClassName="xl:h-[calc(100vh-100px)]"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCode(options: PayEmbedPlaygroundOptions) {
|
||||
const walletCodes: string[] = [];
|
||||
const imports = {
|
||||
react: ["PayEmbed"] as string[],
|
||||
thirdweb: [] as string[],
|
||||
wallets: [] as string[],
|
||||
chains: [] as string[],
|
||||
};
|
||||
|
||||
// Check if we have a custom chain (not base chain which has id 8453)
|
||||
const isCustomChain =
|
||||
options.payOptions.buyTokenChain &&
|
||||
options.payOptions.buyTokenChain.id !== 8453;
|
||||
|
||||
if (isCustomChain) {
|
||||
// Add defineChain to imports if using a custom chain
|
||||
imports.thirdweb.push("defineChain");
|
||||
} else {
|
||||
// Otherwise use the base chain
|
||||
imports.chains.push("base");
|
||||
}
|
||||
|
||||
// Generate chain reference code
|
||||
let chainCode: string;
|
||||
if (isCustomChain && options.payOptions.buyTokenChain?.id) {
|
||||
chainCode = `defineChain(${options.payOptions.buyTokenChain.id})`;
|
||||
} else {
|
||||
chainCode = "base";
|
||||
}
|
||||
|
||||
for (const wallet of options.connectOptions.walletIds) {
|
||||
walletCodes.push(`createWallet("${wallet}")`);
|
||||
}
|
||||
|
||||
if (options.connectOptions.walletIds.length > 0) {
|
||||
imports.wallets.push("createWallet");
|
||||
}
|
||||
|
||||
let themeProp: string | undefined;
|
||||
if (
|
||||
options.theme.type === "dark" &&
|
||||
Object.keys(options.theme.darkColorOverrides || {}).length > 0
|
||||
) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("darkTheme");
|
||||
}
|
||||
|
||||
if (options.theme.type === "light") {
|
||||
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.connectOptions.enableAccountAbstraction) {
|
||||
imports.chains.push("sepolia");
|
||||
}
|
||||
|
||||
// Generate payOptions based on the mode
|
||||
let payOptionsCode = "{";
|
||||
|
||||
if (options.payOptions.title || options.payOptions.image) {
|
||||
payOptionsCode += `
|
||||
metadata: {
|
||||
${options.payOptions.title ? `name: ${quotes(options.payOptions.title)},` : ""}
|
||||
${options.payOptions.image ? `image: ${quotes(options.payOptions.image)},` : ""}
|
||||
},`;
|
||||
}
|
||||
|
||||
// Add mode-specific options
|
||||
if (options.payOptions.mode) {
|
||||
payOptionsCode += `
|
||||
mode: "${options.payOptions.mode}",`;
|
||||
|
||||
// Add buyWithCrypto and buyWithFiat if they're set to false
|
||||
if (options.payOptions.buyWithCrypto === false) {
|
||||
payOptionsCode += `
|
||||
buyWithCrypto: false,`;
|
||||
}
|
||||
|
||||
if (options.payOptions.buyWithFiat === false) {
|
||||
payOptionsCode += `
|
||||
buyWithFiat: false,`;
|
||||
}
|
||||
|
||||
if (options.payOptions.mode === "fund_wallet" || !options.payOptions.mode) {
|
||||
payOptionsCode += `
|
||||
prefillBuy: {
|
||||
chain: ${chainCode},
|
||||
amount: ${options.payOptions.buyTokenAmount ? quotes(options.payOptions.buyTokenAmount) : '"0.01"'},
|
||||
${options.payOptions.buyTokenInfo ? `token: ${JSON.stringify(options.payOptions.buyTokenInfo)},` : ""}
|
||||
},`;
|
||||
} else if (options.payOptions.mode === "direct_payment") {
|
||||
payOptionsCode += `
|
||||
paymentInfo: {
|
||||
chain: ${chainCode},
|
||||
sellerAddress: ${options.payOptions.sellerAddress ? quotes(options.payOptions.sellerAddress) : '"0x0000000000000000000000000000000000000000"'},
|
||||
amount: ${options.payOptions.buyTokenAmount ? quotes(options.payOptions.buyTokenAmount) : '"0.01"'},
|
||||
${options.payOptions.buyTokenInfo ? `token: ${JSON.stringify(options.payOptions.buyTokenInfo)},` : ""}
|
||||
},`;
|
||||
} else if (options.payOptions.mode === "transaction") {
|
||||
payOptionsCode += `
|
||||
transaction: claimTo({
|
||||
contract: myNftContract,
|
||||
quantity: 1n,
|
||||
tokenId: 0n,
|
||||
to: "0x...",
|
||||
}),`;
|
||||
}
|
||||
}
|
||||
|
||||
payOptionsCode += `
|
||||
}`;
|
||||
|
||||
const accountAbstractionCode = options.connectOptions.enableAccountAbstraction
|
||||
? `\n accountAbstraction: {
|
||||
chain: ${isCustomChain ? `defineChain(${options.payOptions.buyTokenChain?.id})` : "base"},
|
||||
sponsorGas: true,
|
||||
}`
|
||||
: "";
|
||||
|
||||
const connectOptionsCode = `${accountAbstractionCode ? `{${accountAbstractionCode}\n }` : ""}`;
|
||||
|
||||
return `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
|
||||
${imports.thirdweb.length > 0 ? `import { ${imports.thirdweb.join(", ")} } from "thirdweb";` : ""}
|
||||
${imports.wallets.length > 0 ? `import { ${imports.wallets.join(", ")} } from "thirdweb/wallets";` : ""}
|
||||
${imports.chains.length > 0 ? `import { ${imports.chains.join(", ")} } from "thirdweb/chains";` : ""}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "....",
|
||||
});
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<PayEmbed
|
||||
client={client}
|
||||
payOptions={${payOptionsCode}}${connectOptionsCode ? `\n connectOptions={${connectOptionsCode}}` : ""}${themeProp ? `\n theme={${themeProp}}` : ""}
|
||||
/>
|
||||
);
|
||||
}`;
|
||||
}
|
||||
|
||||
function quotes(value: string) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Chain } from "thirdweb";
|
||||
import type { LocaleId, ThemeOverrides, TokenInfo } from "thirdweb/react";
|
||||
import type { WalletId } from "thirdweb/wallets";
|
||||
|
||||
export type PayEmbedPlaygroundOptions = {
|
||||
theme: {
|
||||
type: "dark" | "light";
|
||||
darkColorOverrides: ThemeOverrides["colors"];
|
||||
lightColorOverrides: ThemeOverrides["colors"];
|
||||
};
|
||||
payOptions: {
|
||||
mode?: "fund_wallet" | "direct_payment" | "transaction";
|
||||
title: string | undefined;
|
||||
image: string | undefined;
|
||||
|
||||
// fund_wallet mode options
|
||||
buyTokenAddress: string | undefined;
|
||||
buyTokenAmount: string | undefined;
|
||||
buyTokenChain: Chain | undefined;
|
||||
buyTokenInfo?: TokenInfo;
|
||||
buyWithCrypto?: boolean;
|
||||
buyWithFiat?: boolean;
|
||||
|
||||
// direct_payment mode options
|
||||
sellerAddress?: string;
|
||||
|
||||
// transaction mode options
|
||||
transactionData?: string; // Simplified for demo; could be more complex in real implementation
|
||||
};
|
||||
connectOptions: {
|
||||
walletIds: WalletId[];
|
||||
modalTitle: string | undefined;
|
||||
modalTitleIcon: string | undefined;
|
||||
localeId: LocaleId;
|
||||
enableAuth: boolean;
|
||||
enableAccountAbstraction: boolean;
|
||||
termsOfServiceLink: string | undefined;
|
||||
privacyPolicyLink: string | undefined;
|
||||
buttonLabel: string | undefined;
|
||||
ShowThirdwebBranding: boolean;
|
||||
requireApproval: boolean;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExternalLinkIcon,
|
||||
FuelIcon,
|
||||
PaletteIcon,
|
||||
Settings2Icon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { Switch } from "../../../../components/ui/switch";
|
||||
import { CollapsibleSection } from "../../sign-in/components/CollapsibleSection";
|
||||
import { ColorFormGroup } from "../../sign-in/components/ColorFormGroup";
|
||||
import type { PayEmbedPlaygroundOptions } from "../components/types";
|
||||
|
||||
export function LeftSection(props: {
|
||||
options: PayEmbedPlaygroundOptions;
|
||||
setOptions: React.Dispatch<React.SetStateAction<PayEmbedPlaygroundOptions>>;
|
||||
}) {
|
||||
const { options, setOptions } = props;
|
||||
const { theme, payOptions } = options;
|
||||
const setThemeType = (themeType: "dark" | "light") => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: {
|
||||
...v.theme,
|
||||
type: themeType,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Local token state that persists between modes
|
||||
const [tokenName, setTokenName] = useState<string>(
|
||||
payOptions.buyTokenInfo?.name || "",
|
||||
);
|
||||
const [tokenSymbol, setTokenSymbol] = useState<string>(
|
||||
payOptions.buyTokenInfo?.symbol || "",
|
||||
);
|
||||
const [tokenAddress, setTokenAddress] = useState<string>(
|
||||
payOptions.buyTokenInfo?.address || "",
|
||||
);
|
||||
const [tokenIcon, setTokenIcon] = useState<string>(
|
||||
payOptions.buyTokenInfo?.icon || "",
|
||||
);
|
||||
|
||||
// Determine if highlighting is needed - if any field is filled but not all required fields
|
||||
const needsHighlighting =
|
||||
(tokenName || tokenSymbol || tokenAddress) &&
|
||||
(!tokenName || !tokenSymbol || !tokenAddress);
|
||||
|
||||
// Update the global state when all required fields are filled
|
||||
useEffect(() => {
|
||||
if (tokenName && tokenSymbol && tokenAddress) {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyTokenInfo: {
|
||||
name: tokenName,
|
||||
symbol: tokenSymbol,
|
||||
address: tokenAddress,
|
||||
...(tokenIcon ? { icon: tokenIcon } : {}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
} else if (!tokenName && !tokenSymbol && !tokenAddress) {
|
||||
// If all fields are empty, set buyTokenInfo to undefined
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyTokenInfo: undefined,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, [tokenName, tokenSymbol, tokenAddress, tokenIcon, setOptions]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* <CollapsibleSection
|
||||
title="Wallets"
|
||||
icon={WalletCardsIcon}
|
||||
defaultOpen
|
||||
triggerContainerClassName="pt-0"
|
||||
>
|
||||
<div className="h-4" />
|
||||
<WalletsSelection {...props} />
|
||||
</CollapsibleSection> */}
|
||||
|
||||
<CollapsibleSection
|
||||
title="Payment Options"
|
||||
icon={CreditCardIcon}
|
||||
defaultOpen
|
||||
>
|
||||
<div className="flex flex-col gap-6 pt-5">
|
||||
<section className="flex flex-col gap-3">
|
||||
<Label htmlFor="pay-mode">Mode</Label>
|
||||
<CustomRadioGroup
|
||||
id="pay-mode"
|
||||
options={[
|
||||
{ value: "fund_wallet", label: "Fund Wallet" },
|
||||
{ value: "direct_payment", label: "Direct Payment" },
|
||||
{ value: "transaction", label: "Transaction" },
|
||||
]}
|
||||
onValueChange={(value) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
mode: value as
|
||||
| "fund_wallet"
|
||||
| "direct_payment"
|
||||
| "transaction",
|
||||
},
|
||||
}));
|
||||
}}
|
||||
value={payOptions.mode || "fund_wallet"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Conditional form fields based on selected mode */}
|
||||
<div className="mt-4">
|
||||
{/* Fund Wallet Mode Options */}
|
||||
{(!payOptions.mode || payOptions.mode === "fund_wallet") && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="buy-token-amount">Amount</Label>
|
||||
<Input
|
||||
id="buy-token-amount"
|
||||
placeholder="0.01"
|
||||
value={payOptions.buyTokenAmount || ""}
|
||||
onChange={(e) =>
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyTokenAmount: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chain selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fund-wallet-chain-id">Chain ID</Label>
|
||||
<Input
|
||||
id="fund-wallet-chain-id"
|
||||
type="number"
|
||||
placeholder="1 (Ethereum)"
|
||||
value={payOptions.buyTokenChain?.id || ""}
|
||||
onChange={(e) => {
|
||||
const chainId = Number.parseInt(e.target.value);
|
||||
if (!Number.isNaN(chainId)) {
|
||||
const chain = defineChain(chainId);
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyTokenChain: chain,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token selection for fund_wallet mode */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-name">Token Name</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="Name"
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
className={
|
||||
needsHighlighting && !tokenName
|
||||
? "border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-symbol">Token Symbol</Label>
|
||||
<Input
|
||||
id="token-symbol"
|
||||
placeholder="Symbol"
|
||||
value={tokenSymbol}
|
||||
onChange={(e) => setTokenSymbol(e.target.value)}
|
||||
className={
|
||||
needsHighlighting && !tokenSymbol
|
||||
? "border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-address">Token Address</Label>
|
||||
<Input
|
||||
id="token-address"
|
||||
placeholder="0x..."
|
||||
value={tokenAddress}
|
||||
onChange={(e) => setTokenAddress(e.target.value)}
|
||||
className={
|
||||
needsHighlighting && !tokenAddress
|
||||
? "border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-icon">
|
||||
Token Icon URL (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="token-icon"
|
||||
placeholder="https://..."
|
||||
value={tokenIcon}
|
||||
onChange={(e) => setTokenIcon(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{needsHighlighting && (
|
||||
<div className="mt-1 text-red-500 text-xs">
|
||||
All three token fields (Name, Symbol, and Address) are
|
||||
required
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct Payment Mode Options */}
|
||||
{payOptions.mode === "direct_payment" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="seller-address">Seller Address</Label>
|
||||
<Input
|
||||
id="seller-address"
|
||||
placeholder="0x0000..."
|
||||
value={payOptions.sellerAddress || ""}
|
||||
onChange={(e) =>
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
sellerAddress: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="payment-amount">Price</Label>
|
||||
<Input
|
||||
id="payment-amount"
|
||||
placeholder="0.01"
|
||||
value={payOptions.buyTokenAmount || ""}
|
||||
onChange={(e) =>
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyTokenAmount: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chain selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="direct-payment-chain-id">Chain ID</Label>
|
||||
<Input
|
||||
id="direct-payment-chain-id"
|
||||
type="number"
|
||||
placeholder="1 (Ethereum)"
|
||||
value={payOptions.buyTokenChain?.id || ""}
|
||||
onChange={(e) => {
|
||||
const chainId = Number.parseInt(e.target.value);
|
||||
if (!Number.isNaN(chainId)) {
|
||||
const chain = defineChain(chainId);
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyTokenChain: chain,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token selection for direct_payment mode - shares state with fund_wallet mode */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-name">Token Name</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="Name"
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
className={
|
||||
needsHighlighting && !tokenName
|
||||
? "border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-symbol">Token Symbol</Label>
|
||||
<Input
|
||||
id="token-symbol"
|
||||
placeholder="Symbol"
|
||||
value={tokenSymbol}
|
||||
onChange={(e) => setTokenSymbol(e.target.value)}
|
||||
className={
|
||||
needsHighlighting && !tokenSymbol
|
||||
? "border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-address">Token Address</Label>
|
||||
<Input
|
||||
id="token-address"
|
||||
placeholder="0x..."
|
||||
value={tokenAddress}
|
||||
onChange={(e) => setTokenAddress(e.target.value)}
|
||||
className={
|
||||
needsHighlighting && !tokenAddress
|
||||
? "border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="token-icon">
|
||||
Token Icon URL (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="token-icon"
|
||||
placeholder="https://..."
|
||||
value={tokenIcon}
|
||||
onChange={(e) => setTokenIcon(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{needsHighlighting && (
|
||||
<div className="mt-1 text-red-500 text-xs">
|
||||
All three token fields (Name, Symbol, and Address) are
|
||||
required
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction Mode Options */}
|
||||
{payOptions.mode === "transaction" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Transaction</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This demo uses a ERC1155 NFT claim transaction. Check the
|
||||
code section for the transaction details. You can provide
|
||||
any contract call or transfer here, the price will be
|
||||
automatically inferred from the transaction itself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<Label>Payment Methods</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Checkbox
|
||||
id="buy-with-crypto"
|
||||
checked={payOptions.buyWithCrypto}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyWithCrypto: checked === true,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="buy-with-crypto" className="text-xs">
|
||||
Buy with Crypto
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Checkbox
|
||||
id="buy-with-fiat"
|
||||
checked={payOptions.buyWithFiat}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
buyWithFiat: checked === true,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="buy-with-fiat" className="text-xs">
|
||||
Buy with Fiat
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Metadata Options" icon={Settings2Icon}>
|
||||
<div className="flex flex-col gap-6 pt-5">
|
||||
{/* Locale TODO (pay) */}
|
||||
{/* <LocaleFormControl
|
||||
connectOptions={options}
|
||||
setConnectOptions={setConnectOptions}
|
||||
/> */}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-4">
|
||||
{/* Modal title */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="modal-title">Title</Label>
|
||||
<Input
|
||||
id="modal-title"
|
||||
placeholder="Product name"
|
||||
value={options.payOptions.title}
|
||||
onChange={(e) =>
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...payOptions,
|
||||
title: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Title Icon */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="modal-title">Image</Label>
|
||||
<Input
|
||||
id="modal-title"
|
||||
placeholder="https://..."
|
||||
value={options.payOptions.image}
|
||||
onChange={(e) =>
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...payOptions,
|
||||
image: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Appearance" icon={PaletteIcon}>
|
||||
{/* Theme */}
|
||||
<section className="flex flex-col gap-3 pt-6">
|
||||
<Label htmlFor="theme"> Theme </Label>
|
||||
<CustomRadioGroup
|
||||
id="theme"
|
||||
options={[
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "light", label: "Light" },
|
||||
]}
|
||||
onValueChange={setThemeType}
|
||||
value={theme.type}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Colors */}
|
||||
<ColorFormGroup
|
||||
theme={options.theme}
|
||||
onChange={(newTheme) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: newTheme,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Sponsor gas fees" icon={FuelIcon}>
|
||||
<div className="mt-4 flex items-start gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="">
|
||||
Abstract away gas fees for users of your app by enabling ERC-4337
|
||||
Account Abstraction
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/account-abstraction/overview"
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Learn more about Account Abstraction
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<Switch
|
||||
checked={options.connectOptions.enableAccountAbstraction}
|
||||
onCheckedChange={(checked) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
connectOptions: {
|
||||
...v.connectOptions,
|
||||
enableAccountAbstraction: checked,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { ZERO_ADDRESS, getContract } from "thirdweb";
|
||||
import { base } from "thirdweb/chains";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import {
|
||||
PayEmbed,
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
useActiveAccount,
|
||||
} from "thirdweb/react";
|
||||
import { type WalletId, createWallet } from "thirdweb/wallets";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { THIRDWEB_CLIENT } from "../../../../lib/client";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { CodeGen } from "../components/CodeGen";
|
||||
import type { PayEmbedPlaygroundOptions } from "../components/types";
|
||||
|
||||
const nftContract = getContract({
|
||||
address: "0xf0d0CBf84005Dd4eC81364D1f5D7d896Bd53D1B8",
|
||||
chain: base,
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
|
||||
type Tab = "ui" | "code";
|
||||
|
||||
export function RightSection(props: {
|
||||
options: PayEmbedPlaygroundOptions;
|
||||
tab?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
|
||||
return "ui";
|
||||
});
|
||||
|
||||
function setPreviewTab(tab: "ui" | "code") {
|
||||
_setPreviewTab(tab);
|
||||
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
|
||||
}
|
||||
|
||||
const account = useActiveAccount();
|
||||
|
||||
const themeObj =
|
||||
props.options.theme.type === "dark"
|
||||
? darkTheme({
|
||||
colors: props.options.theme.darkColorOverrides,
|
||||
})
|
||||
: lightTheme({
|
||||
colors: props.options.theme.lightColorOverrides,
|
||||
});
|
||||
|
||||
const embed = (
|
||||
<PayEmbed
|
||||
client={THIRDWEB_CLIENT}
|
||||
theme={themeObj}
|
||||
// locale={connectOptions.localeId}
|
||||
connectOptions={{
|
||||
accountAbstraction: props.options.connectOptions
|
||||
.enableAccountAbstraction
|
||||
? {
|
||||
chain: props.options.payOptions.buyTokenChain || base,
|
||||
sponsorGas: true,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
payOptions={{
|
||||
metadata: {
|
||||
name:
|
||||
props.options.payOptions.title ||
|
||||
(props.options.payOptions.mode === "transaction"
|
||||
? "Transaction"
|
||||
: props.options.payOptions.mode === "direct_payment"
|
||||
? "Purchase"
|
||||
: "Fund Wallet"),
|
||||
image:
|
||||
props.options.payOptions.image ||
|
||||
`https://placehold.co/600x400/${
|
||||
props.options.theme.type === "dark"
|
||||
? "1d1d23/7c7a85"
|
||||
: "f2eff3/6f6d78"
|
||||
}?text=Your%20Product%20Here&font=roboto`,
|
||||
},
|
||||
|
||||
// Mode-specific options
|
||||
// biome-ignore lint/suspicious/noExplicitAny: union type
|
||||
mode: (props.options.payOptions.mode as any) || "fund_wallet",
|
||||
|
||||
// Only include buyWithCrypto and buyWithFiat when they're false
|
||||
...(props.options.payOptions.buyWithCrypto === false
|
||||
? { buyWithCrypto: false }
|
||||
: {}),
|
||||
...(props.options.payOptions.buyWithFiat === false
|
||||
? { buyWithFiat: false }
|
||||
: {}),
|
||||
|
||||
...(props.options.payOptions.mode === "fund_wallet" ||
|
||||
!props.options.payOptions.mode
|
||||
? {
|
||||
// Fund wallet mode options
|
||||
prefillBuy: {
|
||||
chain: props.options.payOptions.buyTokenChain || base,
|
||||
amount: props.options.payOptions.buyTokenAmount || "0.01",
|
||||
...(props.options.payOptions.buyTokenInfo
|
||||
? {
|
||||
token: props.options.payOptions.buyTokenInfo,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
...(props.options.payOptions.mode === "direct_payment"
|
||||
? {
|
||||
// Direct payment mode options
|
||||
paymentInfo: {
|
||||
chain: props.options.payOptions.buyTokenChain || base,
|
||||
sellerAddress:
|
||||
props.options.payOptions.sellerAddress ||
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
amount: props.options.payOptions.buyTokenAmount || "0.01",
|
||||
...(props.options.payOptions.buyTokenInfo
|
||||
? {
|
||||
token: props.options.payOptions.buyTokenInfo,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
...(props.options.payOptions.mode === "transaction"
|
||||
? {
|
||||
// Transaction mode options (simplified for demo)
|
||||
transaction: claimTo({
|
||||
contract: nftContract,
|
||||
quantity: 1n,
|
||||
tokenId: 2n,
|
||||
to: account?.address || ZERO_ADDRESS,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-4 xl:sticky xl:top-0 xl:max-h-[100vh] xl:w-[764px]">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "UI",
|
||||
isActive: previewTab === "ui",
|
||||
onClick: () => setPreviewTab("ui"),
|
||||
},
|
||||
{
|
||||
name: "Code",
|
||||
isActive: previewTab === "code",
|
||||
onClick: () => setPreviewTab("code"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-[300px] grow justify-center rounded-lg",
|
||||
previewTab !== "code" && "items-center",
|
||||
)}
|
||||
>
|
||||
<BackgroundPattern />
|
||||
|
||||
{previewTab === "ui" && embed}
|
||||
|
||||
{previewTab === "code" && <CodeGen options={props.options} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function getWallets(walletIds: WalletId[]) {
|
||||
const wallets = [
|
||||
...walletIds.map((id) => {
|
||||
if (id === "xyz.abs") {
|
||||
return abstractWallet();
|
||||
}
|
||||
return createWallet(id);
|
||||
}),
|
||||
];
|
||||
|
||||
return wallets;
|
||||
}
|
||||
|
||||
function BackgroundPattern() {
|
||||
const color = "hsl(var(--foreground)/15%)";
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-[-1]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(${color} 1px, transparent 1px)`,
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButtons(props: {
|
||||
tabs: Array<{
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-start gap-1 rounded-lg border bg-muted p-2 shadow-md md:inline-flex">
|
||||
{props.tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.name}
|
||||
onClick={tab.onClick}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"gap-2 px-4 text-base",
|
||||
tab.isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "bg-transparent text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import { use, useState } from "react";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { base } from "thirdweb/chains";
|
||||
import type { PayEmbedPlaygroundOptions } from "../components/types";
|
||||
import { LeftSection } from "./LeftSection";
|
||||
import { RightSection } from "./RightSection";
|
||||
|
||||
// NOTE: Only set the values that are actually the default values used by Connect component
|
||||
const defaultConnectOptions: PayEmbedPlaygroundOptions = {
|
||||
theme: {
|
||||
type: "dark",
|
||||
darkColorOverrides: {},
|
||||
lightColorOverrides: {},
|
||||
},
|
||||
payOptions: {
|
||||
mode: "fund_wallet",
|
||||
title: "",
|
||||
image: "",
|
||||
buyTokenAddress: NATIVE_TOKEN_ADDRESS,
|
||||
buyTokenAmount: "0.01",
|
||||
buyTokenChain: base,
|
||||
sellerAddress: "",
|
||||
transactionData: "",
|
||||
buyTokenInfo: undefined,
|
||||
buyWithCrypto: true,
|
||||
buyWithFiat: true,
|
||||
},
|
||||
connectOptions: {
|
||||
walletIds: [
|
||||
"io.metamask",
|
||||
"com.coinbase.wallet",
|
||||
"me.rainbow",
|
||||
"io.rabby",
|
||||
"io.zerion.wallet",
|
||||
],
|
||||
modalTitle: undefined,
|
||||
modalTitleIcon: undefined,
|
||||
localeId: "en_US",
|
||||
enableAuth: false,
|
||||
termsOfServiceLink: undefined,
|
||||
privacyPolicyLink: undefined,
|
||||
enableAccountAbstraction: false,
|
||||
buttonLabel: undefined,
|
||||
ShowThirdwebBranding: true,
|
||||
requireApproval: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function PayEmbedPlayground(props: {
|
||||
searchParams: Promise<{ tab: string }>;
|
||||
}) {
|
||||
const searchParams = use(props.searchParams);
|
||||
const [options, setOptions] = useState<PayEmbedPlaygroundOptions>(
|
||||
defaultConnectOptions,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection options={options} setOptions={setOptions} />
|
||||
</div>
|
||||
|
||||
<RightSection tab={searchParams.tab} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { APIHeader } from "@/components/blocks/APIHeader";
|
||||
import { CodeExample } from "@/components/code/code-example";
|
||||
import { StyledPayEmbedPreview } from "@/components/pay/embed";
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { metadataBase } from "@/lib/constants";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
title: "Fund wallets | thirdweb Universal Bridge",
|
||||
description:
|
||||
"The easiest way for users to fund their wallets. Onramp users in clicks and generate revenue for each user transaction. Integrate for free.",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<main className="container px-0 pb-20">
|
||||
<APIHeader
|
||||
title="The easiest way for users to fund their wallets"
|
||||
description={
|
||||
<>
|
||||
Onramp users with credit card & cross-chain crypto payments —
|
||||
and generate revenue for each user transaction.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/pay/get-started"
|
||||
heroLink="/pay.png"
|
||||
/>
|
||||
|
||||
<section className="space-y-8">
|
||||
<StyledPayEmbed />
|
||||
</section>
|
||||
</main>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledPayEmbed() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
|
||||
Fund Wallet
|
||||
</h2>
|
||||
<p className="max-w-[600px]">
|
||||
Inline component that allows users to buy any currency.
|
||||
<br />
|
||||
Customize theme, currency, amounts, payment methods and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CodeExample
|
||||
preview={<StyledPayEmbedPreview />}
|
||||
code={`
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<PayEmbed
|
||||
client={client}
|
||||
payOptions={{
|
||||
mode: "fund_wallet",
|
||||
metadata: {
|
||||
name: "Get funds",
|
||||
},
|
||||
prefillBuy: {
|
||||
chain: base,
|
||||
amount: "0.01",
|
||||
},
|
||||
// ... theme, currency, amounts, payment methods, etc.
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};`}
|
||||
lang="tsx"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
import { APIHeader } from "@/components/blocks/APIHeader";
|
||||
import { CodeExample } from "@/components/code/code-example";
|
||||
import { StyledPayEmbedPreview } from "@/components/pay/embed";
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { metadataBase } from "@/lib/constants";
|
||||
import type { Metadata } from "next";
|
||||
import { APIHeader } from "../../../components/blocks/APIHeader";
|
||||
import PayEmbedPlayground from "./embed/page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
title: "Integrate Fiat & Cross-Chain Crypto Payments | thirdweb Pay",
|
||||
title:
|
||||
"Integrate Fiat & Cross-Chain Crypto Payments | thirdweb Universal Bridge",
|
||||
description:
|
||||
"The easiest way for users to transact in your app. Onramp users in clicks and generate revenue for each user transaction. Integrate for free.",
|
||||
"The easiest way for users to transact in your app. Onramp users, pay with any token and generate revenue for each user transaction. Integrate for free.",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
export default function Page(props: {
|
||||
searchParams: Promise<{ tab: string }>;
|
||||
}) {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<main className="container px-0 pb-20">
|
||||
<div className="">
|
||||
<APIHeader
|
||||
title="The easiest way for users to fund their wallets"
|
||||
title="Universal Bridge UI component"
|
||||
description={
|
||||
<>
|
||||
Onramp users with credit card & cross-chain crypto payments —
|
||||
@@ -28,53 +30,8 @@ export default function Page() {
|
||||
heroLink="/pay.png"
|
||||
/>
|
||||
|
||||
<section className="space-y-8">
|
||||
<StyledPayEmbed />
|
||||
</section>
|
||||
</main>
|
||||
<PayEmbedPlayground searchParams={props.searchParams} />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function StyledPayEmbed() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
|
||||
Fund Wallet
|
||||
</h2>
|
||||
<p className="max-w-[600px]">
|
||||
Inline component that allows users to buy any currency.
|
||||
<br />
|
||||
Customize theme, currency, amounts, payment methods and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CodeExample
|
||||
preview={<StyledPayEmbedPreview />}
|
||||
code={`
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<PayEmbed
|
||||
client={client}
|
||||
payOptions={{
|
||||
mode: "fund_wallet",
|
||||
metadata: {
|
||||
name: "Get funds",
|
||||
},
|
||||
prefillBuy: {
|
||||
chain: base,
|
||||
amount: "0.01",
|
||||
},
|
||||
// ... theme, currency, amounts, payment methods, etc.
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};`}
|
||||
lang="tsx"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,8 +201,13 @@ export function LeftSection(props: {
|
||||
|
||||
{/* Colors */}
|
||||
<ColorFormGroup
|
||||
connectOptions={connectOptions}
|
||||
setConnectOptions={setConnectOptions}
|
||||
theme={connectOptions.theme}
|
||||
onChange={(newTheme) => {
|
||||
setConnectOptions((v) => ({
|
||||
...v,
|
||||
theme: newTheme,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user