diff --git a/.changeset/breezy-balloons-ring.md b/.changeset/breezy-balloons-ring.md deleted file mode 100644 index 68e703501c4..00000000000 --- a/.changeset/breezy-balloons-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@thirdweb-dev/service-utils": patch ---- - -[service-utils] Helper to call client usageV2 reporting endpoint diff --git a/.changeset/config.json b/.changeset/config.json index 4db1fe225f5..fb918b6d5b9 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,11 @@ { + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true, + "updateInternalDependents": "always" + }, "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", + "access": "public", + "baseBranch": "main", "changelog": [ "@changesets/changelog-github", { @@ -7,15 +13,15 @@ } ], "commit": false, - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": ["playground-web", "thirdweb-dashboard", "wallet-ui", "portal"], - "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { - "updateInternalDependents": "always", - "onlyUpdatePeerDependentsWhenOutOfRange": true - }, + "ignore": [ + "playground-web", + "thirdweb-dashboard", + "wallet-ui", + "portal", + "nebula-app" + ], "snapshot": { "prereleaseTemplate": "{tag}-{commit}-{datetime}" - } + }, + "updateInternalDependencies": "patch" } diff --git a/.changeset/dull-lamps-share.md b/.changeset/dull-lamps-share.md deleted file mode 100644 index 9b878ce7e9d..00000000000 --- a/.changeset/dull-lamps-share.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"thirdweb": patch ---- - -Batch approvals and swaps if using smart wallets diff --git a/.changeset/fresh-weeks-deliver.md b/.changeset/fresh-weeks-deliver.md deleted file mode 100644 index d26a167c82b..00000000000 --- a/.changeset/fresh-weeks-deliver.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@thirdweb-dev/service-utils": patch ---- - -[service-utils] Omit team_id for client usageV2 events diff --git a/.changeset/hungry-books-jump.md b/.changeset/hungry-books-jump.md deleted file mode 100644 index dea7a1c4950..00000000000 --- a/.changeset/hungry-books-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@thirdweb-dev/service-utils": patch ---- - -[service-utils] fix auth for cf workers diff --git a/.changeset/metal-crabs-beg.md b/.changeset/metal-crabs-beg.md deleted file mode 100644 index 654af6012e7..00000000000 --- a/.changeset/metal-crabs-beg.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@thirdweb-dev/service-utils": patch ---- - -[service-utils] Allow client-side usageV2 reporting diff --git a/.cursor/rules/dashboard.mdc b/.cursor/rules/dashboard.mdc new file mode 100644 index 00000000000..8b4745fe927 --- /dev/null +++ b/.cursor/rules/dashboard.mdc @@ -0,0 +1,128 @@ +--- +description: Rules for writing features in apps/dashboard +globs: dashboard +alwaysApply: false +--- +# Reusable Core UI Components + +- Always import from the central UI library under `@/components/ui/*` – e.g. `import { Button } from "@/components/ui/button"`. +- Prefer composable primitives over custom markup: `Button`, `Input`, `Select`, `Tabs`, `Card`, `Sidebar`, `Separator`, `Badge`. +- Use `NavLink` (`@/components/ui/NavLink`) for internal navigation so active states are handled automatically. +- Layouts should reuse `SidebarLayout` / `FullWidthSidebarLayout` (`@/components/blocks/SidebarLayout`). +- For notices & skeletons rely on `AnnouncementBanner`, `GenericLoadingPage`, `EmptyStateCard`. +- Icons come from `lucide-react` or the project-specific `…/icons` exports – never embed raw SVG. +- Group related components in their own folder and expose a single barrel `index.ts` where necessary. +- Keep components pure; fetch data outside (server component or hook) and pass it down via props. + +# Styling + +- Tailwind CSS is **the** styling system – avoid inline styles or CSS modules. +- Merge class names with `cn` from `@/lib/utils` to keep conditional logic readable. +- Stick to design-tokens: background (`bg-card`), borders (`border-border`), muted text (`text-muted-foreground`) etc. +- Use the `container` class with a `max-w-7xl` cap for page width consistency. +- Spacing utilities (`px-*`, `py-*`, `gap-*`) are preferred over custom margins. +- Responsive helpers follow mobile-first (`max-sm`, `md`, `lg`, `xl`). +- Never hard-code colors – always go through Tailwind variables. +- Add `className` to the root element of every component for external overrides. + +# Creating a new Component + +- Place the file close to its feature: `feature/components/MyComponent.tsx`. +- Name files after the component in **PascalCase**; append `.client.tsx` when interactive. +- Client components must start with `'use client';` before imports. +- Accept a typed `props` object and export a **named** function (`export function MyComponent()`). +- Reuse core UI primitives; avoid re-implementing buttons, cards, modals. +- Combine class names via `cn`, expose `className` prop if useful. +- Local state or effects live inside; data fetching happens in hooks. +- Provide a Storybook story (`MyComponent.stories.tsx`) or unit test alongside the component. + +# When to use Server Side Rendering (Server Components) + +- Reading cookies/headers with `next/headers` (`getAuthToken()`, `cookies()`). +- Accessing server-only environment variables or secrets. +- Heavy data fetching that should not ship to the client (e.g. analytics, billing). +- Redirect logic using `redirect()` from `next/navigation`. +- Building layout shells (`layout.tsx`) and top-level pages that mainly assemble data. +- Export default async functions without `'use client';` – they run on the Node edge. +- Co-locate data helpers under `@/api/**` and mark them with `"server-only"`. + +# When to use Client Side Rendering (Client Components) + +- Interactive UI that relies on hooks (`useState`, `useEffect`, React Query, wallet hooks). +- Components that listen to user events, animations or live updates. +- When you need access to browser APIs (localStorage, window, IntersectionObserver etc.). +- Pages requiring fast transitions where data is prefetched on the client. +- Anything that consumes hooks from `@tanstack/react-query` or thirdweb SDKs. + +# Fetching Authenticated Data – Server + +```ts +import "server-only"; +import { API_SERVER_URL } from "@/constants/env"; +import { getAuthToken } from "@/app/(app)/api/lib/getAuthToken"; + +export async function getProjects(teamSlug: string) { + const token = await getAuthToken(); + if (!token) return []; + const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/projects`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return res.ok ? (await res.json()).result : []; +} +``` + +Guidelines: + +- Always call `getAuthToken()` to get the JWT from cookies. +- Prefix files with `import "server-only";` so they never end up in the client bundle. +- Pass the token in the `Authorization: Bearer` header – never embed it in the URL. +- Return typed results (`Project[]`, `User[]`, …) – avoid `any`. + +# Fetching Authenticated Data – Client + +```ts +import { useQuery } from "@tanstack/react-query"; +import { fetchJson } from "@/lib/fetch-json"; + +export function useProjects(teamSlug: string) { + return useQuery({ + queryKey: ["projects", teamSlug], + queryFn: () => fetchJson(`/api/projects?team=${teamSlug}`), // internal API route handles token + staleTime: 60_000, + }); +} +``` + +Guidelines: + +- Use **React Query** (`@tanstack/react-query`) for all client data fetching. +- Create light wrappers (e.g. `fetchJson`) that automatically attach the JWT from cookies/session when calling internal API routes. +- Keep `queryKey` stable and descriptive for cache hits. +- Prefer API routes or server actions to keep tokens secret; the browser only sees relative paths. +- Configure `staleTime` / `cacheTime` according to freshness requirements. + +# Analytics Event Reporting + +- **Add events intentionally** – only when they answer a concrete product/business question. +- **Event name**: human-readable ` ` phrase (e.g. `"contract deployed"`). +- **Reporting helper**: `report` (PascalCase); all live in `src/@/analytics/report.ts`. +- **Mandatory JSDoc**: explain *Why* the event exists and *Who* owns it (`@username`). +- **Typed properties**: accept a single `properties` object and pass it unchanged to `posthog.capture`. +- **Client-side only**: never import `posthog-js` in server components. +- **Housekeeping**: ping **#core-services** before renaming or removing an event. + +```ts +/** + * ### Why do we need to report this event? + * - Tracks number of contracts deployed + * + * ### Who is responsible for this event? + * @jnsdls + */ +export function reportContractDeployed(properties: { + address: string; + chainId: number; +}) { + posthog.capture("contract deployed", properties); +} +``` diff --git a/.github/composite-actions/install/action.yml b/.github/composite-actions/install/action.yml index ace4135c237..57aecf82ecf 100644 --- a/.github/composite-actions/install/action.yml +++ b/.github/composite-actions/install/action.yml @@ -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 diff --git a/.github/contributing.md b/.github/contributing.md index b0ce5e1e4db..ccc3935f6d9 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -6,8 +6,6 @@ To get started, read the [How this repo works](#how-this-repo-works) section bel From there, you can take a look at our [Good First Issues](https://github.com/thirdweb-dev/js/labels/good%20first%20issue) board and find an issue that interests you! -If you have any questions about the issue, feel free to ask on our [Discord server](https://discord.gg/thirdweb) in the `#contributors` channel; where you'll be able to get help from our team and other contributors. -
## How this repo works @@ -16,7 +14,7 @@ We use [Turborepo](https://turbo.build/repo/docs) to manage the repository, and We use [pnpm](https://pnpm.io) for package management across the repo. `pnpm` is similar to `npm` or `yarn` but with more efficient disk space usage. -**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](./packages/thirdweb). You can still find the legacy packages at [/legacy_packages](./legacy_packages).** +**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](../packages/thirdweb). You can still find the legacy packages at [/legacy_packages](../legacy_packages).** This single package provides a performant & lightweight SDK to interact with any EVM chain across Node, React, and React Native. Learn more about how to use the thirdweb SDK in our [documentation](https://portal.thirdweb.com/typescript/v5). @@ -85,7 +83,10 @@ If your test depends on a downstream network call, you must mock the call using import { setupServer } from "msw/node"; import { downloadMock, uploadMock } from "../../../test/src/mocks/storage.js"; -const server = setupServer(uploadMock("HASH"), downloadMock({ name: "Test NFT" })); +const server = setupServer( + uploadMock("HASH"), + downloadMock({ name: "Test NFT" }) +); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2dd47e3dcac..6b8bb65aa4f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -16,17 +16,18 @@ env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TW_SECRET_KEY: ${{ secrets.TW_SECRET_KEY }} + TW_CLIENT_ID: ${{ secrets.TW_CLIENT_ID }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} jobs: optimize_ci: - runs-on: ubuntu-latest + runs-on: ubuntu-latest-8 outputs: skip: ${{ steps.check_skip.outputs.skip }} 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 }} @@ -34,11 +35,11 @@ jobs: build: needs: optimize_ci if: needs.optimize_ci.outputs.skip == 'false' - runs-on: ubuntu-latest + runs-on: ubuntu-latest-8 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 @@ -51,18 +52,18 @@ jobs: if: needs.optimize_ci.outputs.skip == 'false' timeout-minutes: 15 name: Lint Packages - runs-on: ubuntu-latest + 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@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1 with: - version: latest + version: 2.0.6 - run: pnpm lint @@ -71,16 +72,16 @@ jobs: if: needs.optimize_ci.outputs.skip == 'false' timeout-minutes: 15 name: Unit Tests - runs-on: ubuntu-latest + 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@82dee4ba654bd2146511f85f0d013af94670c4de # v1.4.0 with: cache: false version: nightly-c4a984fbf2c48b793c8cd53af84f56009dd1070c @@ -88,7 +89,7 @@ jobs: - run: pnpm test - name: Code Coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: directory: packages/ flags: packages @@ -99,14 +100,14 @@ jobs: if: needs.optimize_ci.outputs.skip == 'false' timeout-minutes: 15 name: E2E Tests - runs-on: ubuntu-latest + runs-on: ubuntu-latest-8 strategy: matrix: - package_manager: [npm, yarn, pnpm, bun] + package_manager: [pnpm] # TODO, reenable [npm, yarn, pnpm, bun] 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 @@ -123,7 +124,14 @@ jobs: mkdir test-project cd test-project npm init -y - ${{ matrix.package_manager }} add react react-dom ../packages/thirdweb + + # Handle different package managers + if [ "${{ matrix.package_manager }}" = "pnpm" ]; then + # Create pnpm workspace + echo '{"name": "test-project", "private": true, "workspaces": ["."]}' > package.json + echo '{"packages": ["../packages/*"]}' > pnpm-workspace.yaml + pnpm add react react-dom ../packages/thirdweb -w + fi - name: Create test file run: | cd test-project @@ -133,7 +141,7 @@ jobs: if: matrix.bundler == 'vite' run: | cd test-project - ${{matrix.package_manager}} add vite + ${{matrix.package_manager}} add vite -w echo 'import { defineConfig } from "vite"; import {resolve} from "path"; export default defineConfig({ build: { lib: { entry: resolve(__dirname, "index.js"), name: "e2e_test" }, outDir: "dist" }});' > vite.config.js npx vite build @@ -141,7 +149,7 @@ jobs: if: matrix.bundler == 'webpack' run: | cd test-project - ${{matrix.package_manager}} add webpack webpack-cli + ${{matrix.package_manager}} add webpack webpack-cli -w echo 'const path = require("path"); module.exports = { mode: "production", entry: "./index.js", output: { path: path.resolve(__dirname, "dist"), filename: "bundle.js" }};' > webpack.config.js npx webpack @@ -149,7 +157,7 @@ jobs: if: matrix.bundler == 'esbuild' run: | cd test-project - ${{matrix.package_manager}} add esbuild + ${{matrix.package_manager}} add esbuild -w npx esbuild index.js --bundle --outdir=dist - name: Verify bundle @@ -169,17 +177,27 @@ jobs: if: github.event_name == 'pull_request' && needs.optimize_ci.outputs.skip == 'false' timeout-minutes: 15 name: "Size" - runs-on: ubuntu-latest + 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 + - name: Build Packages + run: pnpm build + + - name: Report bundle size (thirdweb) + uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + package_manager: pnpm + directory: packages/thirdweb + + - name: Report bundle size (nexus) + uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} package_manager: pnpm - directory: packages/thirdweb \ No newline at end of file + directory: packages/nexus \ No newline at end of file diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 98219b8a28e..cdd6944e709 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -2,7 +2,7 @@ name: Auto Author Assign on: pull_request: - types: [opened, reopened, ready_for_review, draft] + types: [opened, reopened, ready_for_review] permissions: pull-requests: write @@ -16,4 +16,4 @@ jobs: github.event.pull_request.author_association == 'COLLABORATOR' || github.event.pull_request.author_association == 'CONTRIBUTOR' steps: - - uses: toshimaru/auto-author-assign@v2.1.1 + - uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d4840395ea0..0531e17c760 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 # ℹ️ 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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml deleted file mode 100644 index 2c65e6d21f8..00000000000 --- a/.github/workflows/issue.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Linked Issue - -on: - pull_request: - types: [opened, edited, ready_for_review] - -env: - VALID_ISSUE_PREFIXES: "CORE|TOOL|NEB|INFRA" - -jobs: - linear: - name: Linear - runs-on: ubuntu-latest - steps: - - name: Check for linked issue - uses: actions/github-script@v7 - with: - script: | - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number - }); - - // Check if contributor is external - const isInternalContributor = ['MEMBER', 'OWNER', 'COLLABORATOR'].includes( - context.payload.pull_request.author_association - ); - - // Automatically pass for external contributors - if (!isInternalContributor) { - console.log('External contributor detected - automatically passing check'); - return; - } - - const body = pr.data.body || ''; - const branchName = pr.data.head.ref; - const issueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES})-\\d+`, 'i'); - const branchIssueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES.toLowerCase()})-\\d+`, 'i'); - - if (!issueRegex.test(body) && !branchIssueRegex.test(branchName)) { - core.setFailed( - `No valid issue reference found. PR body or branch name must contain an issue ID with one of these prefixes: ${process.env.VALID_ISSUE_PREFIXES}` - ); - return; - } - - const matches = body.match(issueRegex) || branchName.match(branchIssueRegex); - console.log(`Found issue reference: ${matches[0]}`); diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index d23c4d403f5..57091aa0a35 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -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 }}" diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 751e83df859..9936ab2ec0d 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37028435f97..4fca608e578 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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@e0145edc7d9d8679003495b11f87bd8ef63c0cba #v1.4.0 with: publish: pnpm release version: pnpm version-packages diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 54e288ca66e..fb620093a82 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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.' diff --git a/.github/workflows/typedoc.yml b/.github/workflows/typedoc.yml index 9ef79f524fa..799d7afe62b 100644 --- a/.github/workflows/typedoc.yml +++ b/.github/workflows/typedoc.yml @@ -21,30 +21,10 @@ 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 - name: Run TypeDoc run: pnpm typedoc - - - name: Update Gist - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GIST_TOKEN }} - script: | - const fs = require('fs'); - const content = fs.readFileSync('./packages/thirdweb/typedoc/parsed.json', 'utf8'); - const gistId = '678fe1f331a01270bb002fee660f285d'; - - await github.rest.gists.update({ - gist_id: gistId, - files: { - 'data.json': { - content: content - } - } - }); - - console.log(`Permalink: https://gist.githubusercontent.com/raw/${gistId}/data.json`); diff --git a/.gitignore b/.gitignore index fc96d534409..ec0919be192 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ yalc.lock ./build/ playwright-report/ .env/ +.pnpm-store/ # codecov binary codecov @@ -30,4 +31,6 @@ packages/*/typedoc/* storybook-static .aider* -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo +.cursor +apps/dashboard/node-compile-cache diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000000..c63239aaee1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* +public-hoist-pattern[]=*pino-pretty* diff --git a/.vscode/settings.json b/.vscode/settings.json index 30c9f5c17ea..918823c26f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,19 +1,10 @@ { - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit", - "source.organizeImports.biome": "explicit", - "quickfix.biome": "explicit" - }, - "editor.defaultFormatter": "biomejs.biome", - "typescript.preferences.autoImportFileExcludePatterns": [ - "./packages/thirdweb/src/exports" - ], - "typescript.tsdk": "node_modules/typescript/lib", - "[typescriptreact]": { + "editor.insertSpaces": true, + "editor.tabSize": 2, + "[css]": { "editor.defaultFormatter": "biomejs.biome" }, - "[typescript]": { + "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { @@ -22,14 +13,31 @@ "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[javascript]": { + "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, - "[css]": { + "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, + "editor.codeActionsOnSave": { + "quickfix.biome": "always", + "source.fixAll.biome": "always", + "source.organizeImports.biome": "always" + }, + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, "eslint.workingDirectories": [ { "pattern": "./packages/*/" }, { "pattern": "./apps/*/" } - ] + ], + "typescript.preferences.autoImportFileExcludePatterns": [ + "./packages/thirdweb/src/exports" + ], + "typescript.preferences.autoImportSpecifierExcludeRegexes": [ + "@radix-ui", + "next/router", + "next/dist", + "^lucide-react/dist/lucide-react.suffixed$" + ], + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..e75c2ed636f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +🤖 Codex Agent Guidelines for thirdweb-dev/js + +Welcome, AI copilots! This guide captures the coding standards, architectural decisions, and workflow conventions that every automated agent (and human contributor!) must follow. Unless a rule explicitly targets a sub‑project, it applies repo‑wide. + +⸻ + +1. GitHub Workflow & Etiquette + +- Pull‑request titles must start with the affected workspace in brackets (e.g. [SDK], [Dashboard], [Portal], [Playground]). +- Begin the PR description with a one‑sentence summary, then add a checklist of changes and reference issues with Fixes #123. +- Keep commits small and topical – one logical change per commit. +- Branch names should follow area/brief-topic (e.g. sdk/fix-gas-estimate). Avoid personal names. +- Request at least one core maintainer review. Do not self‑merge unless you are the sole owner of that package. +- All CI checks (type‑check, Biome, tests) must pass before merging. + +⸻ + +2. Formatting & Linting + +- Biome governs formatting and linting; its rules live in biome.json. +- Run `pnpm fix` & `pnpm lint` before committing, make sure there are no linting errors. +- Avoid editor‑specific configs; rely on the shared settings. +- make sure everything builds after each file change by running `pnpm build` + +⸻ + +3. TypeScript Style Guide + +- Write idiomatic TypeScript: explicit function declarations and return types. +- Limit each file to one stateless, single‑responsibility function for clarity and testability. +- Re‑use shared types from @/types or local types.ts barrels. +- Prefer type aliases over interface except for nominal shapes. +- Avoid any and unknown unless unavoidable; narrow generics whenever possible. +- Choose composition over inheritance; leverage utility types (Partial, Pick, etc.). + +⸻ + +4. Testing Strategy + +- Co‑locate tests: foo.ts ↔ foo.test.ts. +- Use real function invocations with stub data; avoid brittle mocks. +- For network interactions, use Mock Service Worker (MSW) to intercept fetch/HTTP calls, mocking only scenarios that are hard to reproduce. +- Keep tests deterministic and side‑effect free; Vitest is pre‑configured. +- to run the tests: `cd packages thirdweb & pnpm test:dev ` + +⸻ + +5. packages/thirdweb + +5.1 Public API Surface + +- Export everything via the exports/ directory, grouped by feature. +- Every public symbol must have comprehensive TSDoc: +- Include at least one @example block that compiles. +- Tag with one custom annotation (@beta, @internal, @experimental, etc.). +- Comment only ambiguous logic; avoid restating TypeScript in prose. + + 5.2 Performance + +- Lazy‑load heavy dependencies inside async paths to keep the initial bundle lean: + +`const { jsPDF } = await import("jspdf");` + +⸻ + +6. apps/dashboard & apps/playground + +6.1 Core UI Toolkit + +- Import primitives from @/components/ui/\_ (e.g. Button, Input, Select, Tabs, Card, Sidebar, Badge, Separator). +- Use NavLink for internal navigation so active states are handled automatically. +- Group feature‑specific components under feature/components/\_ and expose a barrel index.ts when necessary. + + 6.2 Styling Conventions + +- Tailwind CSS is the styling system – no inline styles or CSS modules. +- Merge class names with cn() from @/lib/utils to keep conditional logic readable. +- Stick to design tokens: backgrounds (bg-card), borders (border-border), muted text (text-muted-foreground), etc. +- Expose a className prop on the root element of every component for overrides. + + 6.3 Component Patterns + +- Server Components (run on the Node edge): + -- Read cookies/headers with next/headers. + -- Access server‑only environment variables or secrets. + -- Perform heavy data fetching that should not ship to the client. + -- Implement redirect logic with redirect() from next/navigation. + -- Start files with import "server-only"; to prevent client bundling. +- Client Components (run in the browser): + -- Begin files with 'use client'; before imports. + -- Handle interactive UI relying on React hooks (useState, useEffect, React Query, wallet hooks). + -- Access browser APIs (localStorage, window, IntersectionObserver, etc.). + -- Support fast transitions where data is prefetched on the client. + + 6.4 Data Fetching Guidelines + +- Server Side + -- Always call getAuthToken() to retrieve the JWT from cookies. + -- Inject the token as an Authorization: Bearer header – never embed it in the URL. + -- Return typed results (Project[], User[], …) – avoid any. +- Client Side + -- Wrap calls in React Query (@tanstack/react-query). + -- Use descriptive, stable queryKeys for cache hits. + -- Configure staleTime / cacheTime based on freshness requirements (default ≥ 60 s). + -- Keep tokens secret by calling internal API routes or server actions. + + 6.5 Analytics Event Reporting + +- **When to create a new event** + -- Only add events that answer a clear product or business question. + -- Check `src/@/analytics/report.ts` first; avoid duplicates. + +- **Naming conventions** + -- **Event name**: human-readable phrase in the form ` ` (e.g. "contract deployed"). + -- **Reporting function**: `report` (PascalCase). + -- All reporting helpers currently live in the shared `report.ts` file. + +- **Boilerplate template** + -- Add a JSDoc header explaining **Why** the event exists and **Who** owns it (`@username`). + -- Accept a single typed `properties` object and forward it unchanged to `posthog.capture`. + -- Example: + +```ts +/** + * ### Why do we need to report this event? + * - Tracks number of contracts deployed + * + * ### Who is responsible for this event? + * @jnsdls + */ +export function reportContractDeployed(properties: { + address: string; + chainId: number; +}) { + posthog.capture("contract deployed", properties); +} +``` + +- **Client-side only**: never import `posthog-js` in server components. +- **Housekeeping**: Inform **#eng-core-services** before renaming or removing an existing event. + +⸻ + +7. Performance & Bundle Size + +- Track bundle budgets via package.json#size-limit. +- Lazy‑import optional features; avoid top‑level side‑effects. +- De‑duplicate dependencies across packages through pnpm workspace hoisting. + +⸻ + +8. Documentation & Developer Experience + +- Each change in packages/\* should contain a changeset for the appropriate package, with the appropriate version bump + + - patch for changes that don't impact the public API + - minor for any new/modified public API + +- Surface breaking changes prominently in PR descriptions. +- For new UI components, add Storybook stories (\*.stories.tsx) alongside the code. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..8f832d18f9c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,290 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Package Management + +- Use `pnpm install` (or `pnpm install --ignore-scripts` on Windows) +- Monorepo managed with Turborepo and pnpm workspaces + +### Building + +```bash +# Build all packages +pnpm build + +# Build specific packages with dependencies +turbo run build --filter=./packages/* + +# Development with watch mode +pnpm dev # Core thirdweb package +pnpm dashboard # Run dashboard + dependencies +pnpm playground # Run playground + dependencies +pnpm portal # Run portal docs + dependencies +``` + +### Testing + +```bash +# Run all tests +pnpm test + +# Interactive testing (thirdweb package) +cd packages/thirdweb && pnpm test:devr + +# Test specific file +pnpm test:dev + +# E2E testing (dashboard) +cd apps/dashboard && pnpm playwright +``` + +### Code Quality + +```bash +# Lint all packages +pnpm lint + +# Auto-fix linting issues +pnpm fix + +# Format code +turbo run format + +# Biome is the primary linter/formatter +``` + +### Development Workflow + +```bash +# Start development server for dashboard +pnpm dashboard + +# Start playground for SDK testing +pnpm playground + +# Generate changeset for releases +pnpm changeset + +# Version packages +pnpm version-packages +``` + +## Repository Architecture + +### Monorepo Structure + +This is a Turborepo monorepo with the main thirdweb v5 SDK consolidated into `/packages/thirdweb/`. Legacy packages are in `/legacy_packages/`. + +### Core Package (`/packages/thirdweb/`) + +**Main Modules:** + +- `client/` - ThirdwebClient foundation +- `chains/` - 50+ supported blockchain definitions +- `contract/` - Contract interaction with automatic ABI resolution +- `transaction/` - Transaction management and execution +- `wallets/` - Comprehensive wallet integration system +- `extensions/` - Modular contract extensions (ERC20, ERC721, etc.) +- `auth/` - SIWE authentication and signature verification +- `pay/` - Fiat and crypto payment infrastructure +- `storage/` - IPFS integration for decentralized storage +- `rpc/` - Low-level blockchain communication + +**Exports Structure:** +The SDK uses modular exports from `src/exports/` including: + +- `thirdweb.ts` - Core client and utilities +- `chains.ts` - Chain definitions +- `wallets.ts` - Wallet connectors +- `react.ts` - React hooks and components +- `extensions/` - Contract standards and protocols + +### Applications (`/apps/`) + +- **dashboard** - Web-based developer console (Next.js, Chakra UI) +- **playground-web** - Interactive SDK testing environment +- **portal** - Documentation site with MDX +- **nebula** - Account abstraction and smart wallet management +- **wallet-ui** - Wallet interface and testing + +### Key Packages (`/packages/`) + +- **thirdweb** - Main SDK (TypeScript, React, React Native) +- **engine** - thirdweb Engine API client +- **insight** - Analytics and data APIs +- **nebula** - Account abstraction client +- **service-utils** - Shared utilities across services + +## Development Practices + +### GitHub Workflow & Pull Requests + +- **PR titles**: Must start with affected workspace in brackets (e.g. `[SDK]`, `[Dashboard]`, `[Portal]`, `[Playground]`) +- **PR descriptions**: Begin with one-sentence summary, add checklist of changes, reference issues with `Fixes #123` +- **Commits**: Keep small and topical – one logical change per commit +- **Branch naming**: Use `area/brief-topic` format (e.g. `sdk/fix-gas-estimate`). Avoid personal names +- **Reviews**: Request at least one core maintainer review. Do not self-merge unless sole package owner +- **CI requirements**: All checks (type-check, Biome, tests) must pass before merging + +### Code Quality & Formatting + +- **Biome**: Primary linter/formatter (rules in `biome.json`) +- **Pre-commit**: Run `pnpm biome check --apply` before committing +- **Build verification**: Run `pnpm build` after each file change to ensure everything builds +- Avoid editor-specific configs; rely on shared settings + +### TypeScript Guidelines + +- **Style**: Write idiomatic TypeScript with explicit function declarations and return types +- **File structure**: Limit each file to one stateless, single-responsibility function for clarity +- **Types**: Re-use shared types from `@/types` or local `types.ts` barrels +- **Interfaces vs Types**: Prefer type aliases over interface except for nominal shapes +- **Type safety**: Avoid `any` and `unknown` unless unavoidable; narrow generics when possible +- **Architecture**: Choose composition over inheritance; leverage utility types (`Partial`, `Pick`, etc.) + +### Testing Strategy + +- **Co-location**: Place tests alongside code: `foo.ts` ↔ `foo.test.ts` +- **Test approach**: Use real function invocations with stub data; avoid brittle mocks +- **Network mocking**: Use Mock Service Worker (MSW) for fetch/HTTP call interception +- **Test quality**: Keep tests deterministic and side-effect free +- **Running tests**: `cd packages/thirdweb && pnpm test:dev ` +- **Test accounts**: Predefined accounts in `test/src/test-wallets.ts` +- **Chain forking**: Use `FORKED_ETHEREUM_CHAIN` for mainnet interactions, `ANVIL_CHAIN` for isolated tests + +### SDK Development (`packages/thirdweb`) + +#### Public API Guidelines +- **Exports**: Export everything via `exports/` directory, grouped by feature +- **Documentation**: Every public symbol must have comprehensive TSDoc with: + - At least one `@example` block that compiles + - Custom annotation tags (`@beta`, `@internal`, `@experimental`) +- **Comments**: Comment only ambiguous logic; avoid restating TypeScript in prose + +#### Performance Optimization +- **Lazy loading**: Load heavy dependencies inside async paths to keep initial bundle lean: + ```typescript + const { jsPDF } = await import("jspdf"); + ``` +- **Bundle budgets**: Track via `package.json#size-limit` +- **Dependencies**: De-duplicate across packages through pnpm workspace hoisting + +### Dashboard & Playground Development + +#### UI Component Standards +- **Core components**: Import primitives from `@/components/ui/*` (Button, Input, Select, Tabs, Card, Sidebar, Badge, Separator) +- **Navigation**: Use `NavLink` for internal navigation with automatic active states +- **Organization**: Group feature-specific components under `feature/components/*` with barrel `index.ts` + +#### Styling Conventions +- **CSS framework**: Tailwind CSS only – no inline styles or CSS modules +- **Class merging**: Use `cn()` from `@/lib/utils` for conditional logic +- **Design tokens**: Use design system tokens (backgrounds: `bg-card`, borders: `border-border`, muted text: `text-muted-foreground`) +- **Component API**: Expose `className` prop on root element for overrides + +#### Component Architecture +- **Server Components** (Node edge): + - Start files with `import "server-only";` + - Read cookies/headers with `next/headers` + - Access server-only environment variables + - Perform heavy data fetching + - Implement redirect logic with `redirect()` from `next/navigation` +- **Client Components** (browser): + - Begin files with `'use client';` + - Handle interactive UI with React hooks (`useState`, `useEffect`, React Query, wallet hooks) + - Access browser APIs (`localStorage`, `window`, `IntersectionObserver`) + - Support fast transitions with prefetched data + +#### Data Fetching Patterns +- **Server Side**: + - Always call `getAuthToken()` to retrieve JWT from cookies + - Use `Authorization: Bearer` header – never embed tokens in URLs + - Return typed results (`Project[]`, `User[]`) – avoid `any` +- **Client Side**: + - Wrap calls in React Query (`@tanstack/react-query`) + - Use descriptive, stable `queryKeys` for cache hits + - Configure `staleTime`/`cacheTime` based on freshness (default ≥ 60s) + - Keep tokens secret via internal API routes or server actions + +#### Analytics Event Guidelines +- **When to add**: Only create events that answer clear product/business questions +- **Check duplicates**: Review `src/@/analytics/report.ts` first +- **Naming**: + - Event name: human-readable ` ` (e.g. "contract deployed") + - Function: `report` (PascalCase) +- **Template**: + ```typescript + /** + * ### Why do we need to report this event? + * - Tracks number of contracts deployed + * + * ### Who is responsible for this event? + * @username + */ + export function reportContractDeployed(properties: { + address: string; + chainId: number; + }) { + posthog.capture("contract deployed", properties); + } + ``` +- **Client-side only**: Never import `posthog-js` in server components +- **Changes**: Inform **#eng-core-services** before renaming/removing events + +### Extension Development + +- Extensions follow modular pattern in `src/extensions/` +- Auto-generated contracts from ABI definitions +- Composable functions with TypeScript safety +- Support for read/write operations + +### Wallet Architecture + +- Unified `Wallet` and `Account` interfaces +- Support for in-app wallets (social/email login) +- Smart wallets with account abstraction +- EIP-1193, EIP-5792, EIP-7702 standard support + +## Contribution Workflow + +1. **Fork and Clone**: Create fork, clone, create feature branch +2. **Install**: `pnpm install` (use `--ignore-scripts` on Windows) +3. **Develop**: Use appropriate dev commands above +4. **Test**: Write unit tests, run linting, test on demo apps +5. **Changeset**: Run `pnpm changeset` for semantic versioning +6. **PR**: Submit pull request to main branch + +### Release Testing + +Comment `/release-pr` on PR to trigger dev release to npm for testing. + +### Changeset Guidelines + +Each change in `packages/*` should contain a changeset for the appropriate package with the appropriate version bump: +- **patch**: Changes that don't impact the public API +- **minor**: Any new/modified public API +- **major**: Breaking changes (surface prominently in PR descriptions) + +### Documentation Standards + +- For new UI components, add Storybook stories (`*.stories.tsx`) alongside the code +- Surface breaking changes prominently in PR descriptions +- Keep documentation focused on developer experience and practical usage + +## Multi-Platform Support + +The SDK supports: + +- **Web**: React hooks and components +- **React Native**: Mobile-specific exports and components +- **Node.js**: Server-side functionality +- **Framework Adapters**: Wagmi, Ethers compatibility layers + +Key files: + +- `src/exports/react.native.ts` - React Native specific exports +- `packages/react-native-adapter/` - Mobile platform shims +- `packages/wagmi-adapter/` - Wagmi ecosystem integration diff --git a/README.md b/README.md index 1967adf2ac8..9f1693e960c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@


- +

@@ -11,44 +11,154 @@ Build Status - - Join our Discord! -

All-in-one web3 SDK for Browser, Node and Mobile apps

-## Features +## Core Package + +#### [`thirdweb`](./packages/thirdweb/README.md) + +The main SDK package providing all-in-one web3 functionality for Browser, Node, and Mobile applications. -- Support for React & React-Native -- First party support for [Embedded Wallets](https://portal.thirdweb.com/connect/embedded-wallet/overview) (social/email login) -- First party support for [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview) -- Instant connection to any chain with RPC Edge integration -- Integrated IPFS upload/download -- UI Components for connection, transactions, nft rendering -- High level contract extensions for interacting with common standards +```bash +npm install thirdweb +``` + +**Features:** + +- Type-safe contract and transaction APIs +- In-app wallets with social/email login +- Account abstraction (ERC4337/EIP7702) support +- 500+ external wallets supported +- Built in infra (RPC, bundler, paymaster, indexer) +- React hooks and UI components - Automatic ABI resolution +- IPFS upload/download +- Cross-platform support (Web, React Native) + +### Documentation + +Visit the [developer portal](https://portal.thirdweb.com) for full documentation. + +### 🚀 Quick Start + +#### For React Applications + +```bash +npm install thirdweb +``` + +```typescript +import { createThirdwebClient } from "thirdweb"; +import { ConnectButton, useActiveAccount } from "thirdweb/react"; + +const client = createThirdwebClient({ + clientId: "YOUR_CLIENT_ID", +}); + +function App() { + const account = useActiveAccount(); + console.log("Connected as", account?.address); + + return ; +} +``` + +For React Native Applications, you'll also need to install the `@thirdweb-dev/react-native-adapter` package and import it at app startup for polyfills. + +#### For Backend Applications + +```bash +npm install thirdweb +``` + +```typescript +import { createThirdwebClient, Engine } from "thirdweb"; + +const client = createThirdwebClient({ + secretKey: "YOUR_SECRET_KEY", +}); + +const wallet = Engine.serverWallet({ + client, + address: "0x...", +}); + +const transaction = transfer({ + contract: getContract({ + client, + address: "0x...", // token contract + chain: defineChain(1), + }), + to: "0x...", // recipient + amount: "0.01", // amount in tokens +}); + +await wallet.enqueueTransaction({ + transaction, +}); +``` + +## Adapters + +#### [`@thirdweb-dev/react-native-adapter`](./packages/react-native-adapter/README.md) + +Required polyfills and configuration for running the thirdweb SDK in React Native applications. + +```bash +npm install @thirdweb-dev/react-native-adapter +``` + +#### [`@thirdweb-dev/wagmi-adapter`](./packages/wagmi-adapter/README.md) + +Integration layer for using thirdweb's in-app wallets with wagmi. + +```bash +npm install @thirdweb-dev/wagmi-adapter +``` + +## Type safe API wrappers + +#### [`@thirdweb-dev/api`](./packages/api/README.md) + +TypeScript SDK for thirdweb's API, combining all of thirdweb products. + +```bash +npm install @thirdweb-dev/api +``` + +#### [`@thirdweb-dev/engine`](./packages/engine/README.md) + +TypeScript SDK for Engine, thirdweb's backend onchain executor service. + +```bash +npm install @thirdweb-dev/engine +``` + +#### [`@thirdweb-dev/insight`](./packages/insight/README.md) + +TypeScript SDK for Insight, thirdweb's multichain indexer service. + +```bash +npm install @thirdweb-dev/insight +``` + +#### [`@thirdweb-dev/vault-sdk`](./packages/vault-sdk/README.md) + +SDK for interacting with Vault, thirdweb's secure key management service. + +```bash +npm install @thirdweb-dev/vault-sdk +``` + +#### [`@thirdweb-dev/nebula`](./packages/nebula/README.md) + +TypeScript SDK for Nebula, thirdweb's AI agent service. -## Library Comparison - -| | thirdweb | Wagmi + Viem | Ethers@6 | -| ----------------------------------------- | -------- | ------------------ | -------- | -| Type safe contract API | ✅ | ✅ | ✅ | -| Type safe wallet API | ✅ | ✅ | ✅ | -| EVM utils | ✅ | ✅ | ✅ | -| RPC for any EVM | ✅  | ⚠️ public RPC only | ❌ | -| Automatic ABI Resolution | ✅ | ❌ | ❌ | -| IPFS Upload/Download | ✅ | ❌ | ❌ | -| Embedded wallet (email/ social login) | ✅ | ⚠️ via 3rd party | ❌ | -| Account abstraction (ERC4337) support | ✅ | ⚠️ via 3rd party | ❌ | -| Web3 wallet connectors | ✅ | ✅ | ❌ | -| Local wallet creation | ✅ | ✅ | ✅ | -| Auth (SIWE) | ✅ | ✅ | ❌ | -| Extensions functions for common standards | ✅ | ✅ | ❌ | -| React Hooks | ✅ | ✅ | ❌ | -| React UI components | ✅ | ❌ | ❌ | -| React Native Hooks | ✅ | ✅ | ❌ | -| React Native UI Components | ✅ | ❌ | ❌ | +```bash +npm install @thirdweb-dev/nebula +``` ## Contributing @@ -58,9 +168,12 @@ See our [open source page](https://thirdweb.com/open-source) for more informatio ## Additional Resources -- [Documentation](https://portal.thirdweb.com/typescript/v5) +- [Dashboard](https://thirdweb.com/login) +- [Documentation](https://portal.thirdweb.com/) - [Templates](https://thirdweb.com/templates) - [YouTube](https://www.youtube.com/c/thirdweb) +- [X/Twitter](https://x.com/thirdweb) +- [Telegram](https://t.me/officialthirdweb) ## Support diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 27bf2a4453f..918d54845d4 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -1,25 +1,22 @@ -# # Public (client) -# +# ------------------------------------------------------------------------ -# API authorized domain -# not required to build, defaults to prod -NEXT_PUBLIC_THIRDWEB_DOMAIN="localhost:3000" - -# API host. For local development, please use "https://api.thirdweb-preview.com" +# API host. For local development, please use "https://api.thirdweb-dev.com" # otherwise: "https://api.thirdweb.com" +# local host - http://127.0.0.1:3005 NEXT_PUBLIC_THIRDWEB_API_HOST="https://api.thirdweb-dev.com" -# Paper API host -NEXT_PUBLIC_THIRDWEB_EWS_API_HOST="https://ews.thirdweb-dev.com" +# Bridge API. For local development, please use "https://bridge.thirdweb-dev.com" +# otherwise: "https://bridge.thirdweb.com" +NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST="https://bridge.thirdweb-dev.com" -# thirdweb local api host -# NEXT_PUBLIC_THIRDWEB_API_HOST="http://127.0.0.1:3005" +# in-app wallet host +NEXT_PUBLIC_THIRDWEB_EWS_API_HOST="https://ews.thirdweb-dev.com" -# Client ID -# Get the client id from https://thirdweb.com/create-api-key and set it here, +# Client ID - Required +# Get the client id from https://thirdweb.com/create-api-key and set it here, and also set DASHBOARD_SECRET_KEY below # make sure to allow localhost:3000 in the allowed origins -NEXT_PUBLIC_DASHBOARD_CLIENT_ID="" +NEXT_PUBLIC_DASHBOARD_CLIENT_ID="" # IPFS gateway url NEXT_PUBLIC_IPFS_GATEWAY_URL="https://{clientId}.thirdwebstorage-dev.com/ipfs/{cid}/{path}" @@ -33,68 +30,42 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY= # posthog API key # - not required for prod/staging -NEXT_PUBLIC_POSTHOG_API_KEY="ignored" +NEXT_PUBLIC_POSTHOG_KEY="" -# Stripe Customer portal -NEXT_PUBLIC_STRIPE_KEY= +# required for server wallet management +NEXT_PUBLIC_THIRDWEB_VAULT_URL="" +NEXT_PUBLIC_ENGINE_CLOUD_URL="" -NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID= -# Needed for contract analytics / blockchain data information -CHAINSAW_API_KEY= +# Demo Engine - required for showing demo engine page +NEXT_PUBLIC_DEMO_ENGINE_URL="" + -# # Private (server) -# -# Get the secret key from https://thirdweb.com/create-api-key and set it here, Make sure to allow localhost:3000 in the allowed origins +# ------------------------------------------------------------------------ + +# Get the secret key from https://thirdweb.com/create-api-key and set it here and also set NEXT_PUBLIC_DASHBOARD_CLIENT_ID above, +# Make sure to allow localhost:3000 in the allowed origins DASHBOARD_SECRET_KEY="" -# Client id for api routes +# Client id for api routes - required for contract OG image generation API_ROUTES_CLIENT_ID= -# -MORALIS_API_KEY= - -# alchemy.com API key (used for wallet NFTS) -# - cannot be restricted to IP/domain because vercel has no stable IPs and it happens during build & runtime api route call -# - not required to build (unless testing wallet NFTs)> -SSR_ALCHEMY_KEY= - -# beehiiv.com API key (used for newsletter signups) -# - not required to build (unless testing newsletter signups)> -BEEHIIV_API_KEY= - -# Hubspot Access Token (used for contact us form) -# - not required to build (unless testing contact us form)> -HUBSPOT_ACCESS_TOKEN= - -# Github API Token (used for /open-source) -GITHUB_API_TOKEN="ghp_..." - -# Upload server url -NEXT_PUBLIC_DASHBOARD_UPLOAD_SERVER="https://storage.thirdweb-preview.com" - -# Unthread variables - only required for submitting the support form in /support -UNTHREAD_API_KEY="" -UNTHREAD_TRIAGE_CHANNEL_ID="" -UNTHREAD_EMAIL_INBOX_ID="" -UNTHREAD_FREE_TIER_ID="" -UNTHREAD_GROWTH_TIER_ID="" -UNTHREAD_PRO_TIER_ID="" - -# Demo Engine -NEXT_PUBLIC_DEMO_ENGINE_URL="" - # API server secret (required for thirdweb.com SIWE login). Copy from Vercel. API_SERVER_SECRET="" -# Used for the Faucet page (/) +# Used in faucet on chain page (/) and login page (/login) - turnstile is disabled in localhost development NEXT_PUBLIC_TURNSTILE_SITE_KEY="" TURNSTILE_SECRET_KEY="" + REDIS_URL="" ANALYTICS_SERVICE_URL="" -# Required for Nebula Chat -NEXT_PUBLIC_NEBULA_URL="" \ No newline at end of file +# required for billing parts of the dashboard (team -> settings -> billing / invoices) +STRIPE_SECRET_KEY="" +GROWTH_PLAN_SKU="" +PAYMENT_METHOD_CONFIGURATION="" + +# required for bridge iframe (/bridge/widget) page +NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID="" \ No newline at end of file diff --git a/apps/dashboard/.eslintrc.js b/apps/dashboard/.eslintrc.js index d19656a5eb7..4bb41fd8667 100644 --- a/apps/dashboard/.eslintrc.js +++ b/apps/dashboard/.eslintrc.js @@ -1,140 +1,18 @@ module.exports = { + env: { + browser: true, + node: true, + }, extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@next/next/recommended", "plugin:storybook/recommended", ], - rules: { - "react-compiler/react-compiler": "error", - "no-restricted-syntax": [ - "error", - { - selector: "CallExpression[callee.name='useEffect']", - message: - 'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".', - }, - { - selector: "CallExpression[callee.name='createContext']", - message: - 'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.', - }, - { - selector: "CallExpression[callee.name='defineChain']", - message: - "Use useV5DashboardChain instead if you are using it inside a component", - }, - { - selector: "CallExpression[callee.name='defineDashboardChain']", - message: - "Use useV5DashboardChain instead if you are using it inside a component", - }, - { - selector: "CallExpression[callee.name='mapV4ChainToV5Chain']", - message: - "Use useV5DashboardChain instead if you are using it inside a component", - }, - { - selector: "CallExpression[callee.name='resolveScheme']", - message: - "resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead", - }, - ], - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "@chakra-ui/react", - // these are provided by tw-components, so we don't want to import them from chakra directly - importNames: [ - "Card", - "Button", - "Checkbox", - "Badge", - "Drawer", - "Heading", - "Text", - "FormLabel", - "FormHelperText", - "FormErrorMessage", - "MenuGroup", - "VStack", - "HStack", - "AspectRatio", - "useToast", - "useClipboard", - "Badge", - "Stack", - // also the types - "ButtonProps", - "BadgeProps", - "DrawerProps", - "HeadingProps", - "TextProps", - "FormLabelProps", - "HelpTextProps", - "MenuGroupProps", - "MenuItemProps", - "AspectRatioProps", - "BadgeProps", - "StackProps", - ], - message: - 'Use the equivalent component from "tw-components" instead.', - }, - { - name: "@chakra-ui/layout", - message: - "Import from `@chakra-ui/react` instead of `@chakra-ui/layout`.", - }, - { - name: "@chakra-ui/button", - message: - "Import from `@chakra-ui/react` instead of `@chakra-ui/button`.", - }, - { - name: "@chakra-ui/menu", - message: - "Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.", - }, - { - name: "next/navigation", - importNames: ["useRouter"], - message: - 'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead', - }, - { - name: "lucide-react", - importNames: ["Link", "Table", "Sidebar"], - message: - 'This is likely a mistake. If you really want to import this - postfix the imported name with Icon. Example - "LinkIcon"', - }, - ], - }, - ], - }, - parser: "@typescript-eslint/parser", - plugins: ["@typescript-eslint", "react-compiler"], - parserOptions: { - ecmaVersion: 2019, - ecmaFeatures: { - impliedStrict: true, - jsx: true, - }, - warnOnUnsupportedTypeScriptVersion: true, - }, - settings: { - react: { - createClass: "createReactClass", - pragma: "React", - version: "detect", - }, - }, overrides: [ - // disable restricted imports in tw-components + // allow direct PostHog imports inside analytics helpers { - files: "src/tw-components/**/*", + files: "src/@/analytics/**/*", rules: { "no-restricted-imports": ["off"], }, @@ -151,8 +29,8 @@ module.exports = { { files: ["*test.ts?(x)"], rules: { - "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", "react/display-name": "off", }, @@ -177,14 +55,119 @@ module.exports = { "import/newline-after-import": "off", }, }, + // turn OFF unused vars via eslint + { + files: ["*.ts", "*.tsx"], + rules: { + "@next/next/no-img-element": "off", + "@typescript-eslint/no-unused-vars": "off", + }, + }, // THIS NEEDS TO GO LAST! { - files: ["*.ts", "*.js", "*.tsx", "*.jsx"], extends: ["biome"], + files: ["*.ts", "*.js", "*.tsx", "*.jsx"], }, ], - env: { - browser: true, - node: true, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + impliedStrict: true, + jsx: true, + }, + ecmaVersion: 2019, + warnOnUnsupportedTypeScriptVersion: true, + }, + plugins: ["@typescript-eslint", "react-compiler"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + importNames: ["useRouter"], + message: + 'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead', + name: "next/navigation", + }, + { + message: + 'Import "posthog-js" directly only within the analytics helpers ("src/@/analytics/*"). Use the exported helpers from "@/analytics/track" elsewhere.', + name: "posthog-js", + }, + { + importNames: ["useSendTransaction"], + message: + 'Use `import { useSendAndConfirmTx } from "@/hooks/useSendTx";` instead', + name: "thirdweb/react", + }, + { + importNames: ["useSendAndConfirmTransaction"], + message: + 'Use `import { useSendAndConfirmTx } from "@/hooks/useSendTx";` instead', + name: "thirdweb/react", + }, + { + importNames: ["sendTransaction"], + message: + 'Use `import { useSendAndConfirmTx } from "@/hooks/useSendTx";` instead if used in react component', + name: "thirdweb", + }, + { + importNames: ["sendAndConfirmTransaction"], + message: + 'Use `import { useSendAndConfirmTx } from "@/hooks/useSendTx";` instead if used in react component', + name: "thirdweb", + }, + ], + patterns: [ + { + group: ["**/../@/**"], + message: "Use absolute imports instead. Example: '@/foo/bar..'", + }, + ], + }, + ], + "no-restricted-syntax": [ + "error", + { + message: + 'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".', + selector: "CallExpression[callee.name='useEffect']", + }, + { + message: + 'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.', + selector: "CallExpression[callee.name='createContext']", + }, + { + message: + "Use useV5DashboardChain instead if you are using it inside a component", + selector: "CallExpression[callee.name='defineChain']", + }, + { + message: + "Use useV5DashboardChain instead if you are using it inside a component", + selector: "CallExpression[callee.name='defineDashboardChain']", + }, + { + message: + "Use useV5DashboardChain instead if you are using it inside a component", + selector: "CallExpression[callee.name='mapV4ChainToV5Chain']", + }, + { + message: + "resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead", + selector: "CallExpression[callee.name='resolveScheme']", + }, + ], + "react-compiler/react-compiler": "error", + }, + settings: { + react: { + createClass: "createReactClass", + pragma: "React", + version: "detect", + }, }, }; diff --git a/apps/dashboard/.storybook/main.ts b/apps/dashboard/.storybook/main.ts index 255aac67d66..ca8b9ca3d78 100644 --- a/apps/dashboard/.storybook/main.ts +++ b/apps/dashboard/.storybook/main.ts @@ -13,19 +13,16 @@ const config: StorybookConfig = { addons: [ getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-links"), - getAbsolutePath("@storybook/addon-essentials"), getAbsolutePath("@chromatic-com/storybook"), - getAbsolutePath("@storybook/addon-interactions"), + getAbsolutePath("@storybook/addon-docs"), ], framework: { name: getAbsolutePath("@storybook/nextjs"), options: {}, }, - refs: { - "@chakra-ui/react": { - disable: true, - }, - }, staticDirs: ["../public"], + features: { + experimentalRSC: true, + }, }; export default config; diff --git a/apps/dashboard/.storybook/preview.tsx b/apps/dashboard/.storybook/preview.tsx index bfba8033060..52c86d39249 100644 --- a/apps/dashboard/.storybook/preview.tsx +++ b/apps/dashboard/.storybook/preview.tsx @@ -1,13 +1,13 @@ -import type { Preview } from "@storybook/react"; -import "@/styles/globals.css"; +import type { Preview } from "@storybook/nextjs"; +import "@workspace/ui/global.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Button } from "@workspace/ui/components/button"; import { MoonIcon, SunIcon } from "lucide-react"; -import { ThemeProvider, useTheme } from "next-themes"; import { Inter as interFont } from "next/font/google"; -// biome-ignore lint/style/useImportType: -import React from "react"; -import { useEffect } from "react"; -import { Button } from "../src/@/components/ui/button"; +import { ThemeProvider, useTheme } from "next-themes"; +// biome-ignore lint/style/useImportType: ok +import React, { useEffect } from "react"; +import { Toaster } from "sonner"; const queryClient = new QueryClient(); @@ -16,8 +16,30 @@ const fontSans = interFont({ variable: "--font-sans", }); +const customViewports = { + xs: { + // Regular sized phones (iphone 15 / 15 pro) + name: "iPhone", + styles: { + width: "390px", + height: "844px", + }, + }, + sm: { + // Larger phones (iphone 15 plus / 15 pro max) + name: "iPhone Plus", + styles: { + width: "430px", + height: "932px", + }, + }, +}; + const preview: Preview = { parameters: { + viewport: { + viewports: customViewports, + }, controls: { matchers: { color: /(background|color)$/i, @@ -46,9 +68,7 @@ const preview: Preview = { export default preview; -function StoryLayout(props: { - children: React.ReactNode; -}) { +function StoryLayout(props: { children: React.ReactNode }) { const { setTheme, theme } = useTheme(); useEffect(() => { @@ -57,29 +77,30 @@ function StoryLayout(props: { return ( -
-
- - +
+
{props.children}
+
); } + +function ToasterSetup() { + const { theme } = useTheme(); + return ; +} diff --git a/apps/dashboard/biome.json b/apps/dashboard/biome.json index b287b3aca79..f9869db792f 100644 --- a/apps/dashboard/biome.json +++ b/apps/dashboard/biome.json @@ -1,16 +1,4 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.2/schema.json", - "extends": ["../../biome.json"], - "overrides": [ - { - "include": ["src/css/swagger-ui.css"], - "linter": { - "rules": { - "suspicious": { - "noImportantInKeyframe": "off" - } - } - } - } - ] + "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", + "extends": "//" } diff --git a/apps/dashboard/checkly.config.ts b/apps/dashboard/checkly.config.ts deleted file mode 100644 index 837a6db2a21..00000000000 --- a/apps/dashboard/checkly.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { defineConfig } from "checkly"; -import { Frequency } from "checkly/constructs"; - -export default defineConfig({ - projectName: "thirdweb.com", - logicalId: "thirdweb-www", - repoUrl: "https://github.com/thirdweb-dev/dashboard", - checks: { - activated: true, - muted: false, - runtimeId: "2023.09", - frequency: Frequency.EVERY_24H, - locations: ["us-east-1", "eu-west-1"], - tags: ["website"], - checkMatch: "./**/*.check.ts", - ignoreDirectoriesMatch: [], - playwrightConfig: { - use: { - baseURL: process.env.ENVIRONMENT_URL || "https://thirdweb.com", - }, - }, - browserChecks: { - frequency: Frequency.EVERY_24H, - testMatch: "./tests/**/*.spec.ts", - }, - }, - cli: { - runLocation: "eu-west-1", - }, -}); diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json index b1dc07eb643..d6f9c61bbe9 100644 --- a/apps/dashboard/components.json +++ b/apps/dashboard/components.json @@ -1,17 +1,17 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + }, "rsc": true, - "tsx": true, + "style": "default", "tailwind": { + "baseColor": "neutral", "config": "tailwind.config.js", "css": "src/global.css", - "baseColor": "neutral", "cssVariables": true, "prefix": "" }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils" - } -} \ No newline at end of file + "tsx": true +} diff --git a/apps/dashboard/framer-rewrites.js b/apps/dashboard/framer-rewrites.js index cf11cb2ca7e..8ec41bbfca5 100644 --- a/apps/dashboard/framer-rewrites.js +++ b/apps/dashboard/framer-rewrites.js @@ -6,27 +6,34 @@ module.exports = [ "/bounties", "/contact-us", // -- product landing pages -- - // -- connect - "/connect", - "/connect/sign-in", - "/connect/account-abstraction", - "/connect/universal-bridge", - // -- nebula - "/nebula", - // --insight + // -- build category + "/wallets", + "/account-abstraction", + "/payments", + "/x402", + "/nexus", + "/auth", + "/in-app-wallets", + "/transactions", + // -- end build category + + // -- scale category + "/rpc", "/insight", + "/storage", + "/gateway", + // -- end scale category + + // -- ai + "/ai", // -- contracts "/contracts", "/contracts/modular-contracts", "/contracts/explore", "/contracts/deployment-tool", - // -- engine - "/engine", + // -- solutions pages -- - "/solutions/gaming", - "/solutions/consumer-apps", - "/solutions/defi", - "/solutions/ecosystem", + "/solutions/:solution_slug", // -- campaigns -- // "/unlimited-wallets", -- OFF for now // -- TPP -- @@ -39,8 +46,29 @@ module.exports = [ "/community/ambassadors", "/community/startup-program", // -- grants -- - "/grant/superchain", + "/grants", + "/superchain", // -- templates -- "/templates", "/templates/:template_slug", + // -- learn -- + "/learn", + "/learn/guides", + "/learn/guides/:guide_slug", + "/learn/courses", + "/learn/glossary", + "/learn/glossary/:glossary_slug", + // -- faucets -- + "/faucets", + // -- brand kit -- + "/brand-kit", + // -- universal bridge landing pages -- + "/universal-bridge-regions/:region_slug", + "/enterprise", + "/token", + "/vault", + "/monetize/bridge", + // ai + "/ai-privacy-policy", + "/ai-terms", ]; diff --git a/apps/dashboard/knip.json b/apps/dashboard/knip.json index 74965db63a5..03076122bc2 100644 --- a/apps/dashboard/knip.json +++ b/apps/dashboard/knip.json @@ -1,13 +1,27 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "next": true, "ignore": [ "src/@/components/ui/**", - "src/components/notices/AnnouncementBanner.tsx", - "src/components/cmd-k-search/index.tsx", - "src/lib/search.ts" + "src/@/components/misc/AnnouncementBanner.tsx", + "src/@/components/cmd-k-search/index.tsx", + "src/@/lib/search.ts" + ], + "ignoreBinaries": ["only-allow"], + "ignoreDependencies": [ + "@thirdweb-dev/api", + "@thirdweb-dev/service-utils", + "@thirdweb-dev/vault-sdk", + "thirdweb", + "@types/color", + "fast-xml-parser", + "@workspace/ui", + "tailwindcss-animate", + "@radix-ui/react-tooltip", + "shiki", + "react-children-utilities", + "react-markdown", + "remark-gfm" ], - "project": ["src/**"], - "ignoreBinaries": ["only-allow", "biome"], - "ignoreDependencies": ["@storybook/blocks", "@thirdweb-dev/service-utils"] + "next": true, + "project": ["src/**"] } diff --git a/apps/dashboard/lucide-react.d.ts b/apps/dashboard/lucide-react.d.ts new file mode 100644 index 00000000000..93f9156d593 --- /dev/null +++ b/apps/dashboard/lucide-react.d.ts @@ -0,0 +1,3 @@ +declare module "lucide-react" { + export * from "lucide-react/dist/lucide-react.suffixed"; +} diff --git a/apps/dashboard/next-sitemap.config.js b/apps/dashboard/next-sitemap.config.js index 27a0ea749c0..1f69b0162dc 100644 --- a/apps/dashboard/next-sitemap.config.js +++ b/apps/dashboard/next-sitemap.config.js @@ -1,7 +1,9 @@ // @ts-check +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { XMLParser } = require("fast-xml-parser"); /** - * + * @returns {Promise>} */ async function fetchChainsFromApi() { const res = await fetch("https://api.thirdweb.com/v1/chains", { @@ -42,12 +44,37 @@ async function getSingleChain(chainIdOrSlug) { /** @type {import('next-sitemap').IConfig} */ module.exports = { - siteUrl: process.env.SITE_URL || "https://thirdweb.com", + additionalPaths: async (config) => { + const [framerUrls, allChains] = await Promise.all([ + getFramerXML(), + fetchChainsFromApi(), + ]); + + return [ + ...framerUrls.map((url) => { + return { + changefreq: config.changefreq, + lastmod: config.autoLastmod ? new Date().toISOString() : undefined, + loc: url.loc, + priority: config.priority, + }; + }), + ...allChains.map((chain) => { + return { + changefreq: config.changefreq, + lastmod: config.autoLastmod ? new Date().toISOString() : undefined, + loc: `/${chain.slug}`, + priority: config.priority, + }; + }), + ...(await createSearchRecordSitemaps(config)), + ]; + }, + exclude: ["/chain/validate"], generateRobotsTxt: true, robotsTxtOptions: { policies: [ { - userAgent: "*", // allow all if production allow: process.env.VERCEL_ENV === "production" ? ["/"] : [], // disallow all if not production @@ -56,10 +83,11 @@ module.exports = { ? ["/"] : // disallow `/team` and `/team/*` if production ["/team", "/team/*"], + userAgent: "*", }, ], }, - exclude: ["/chain/validate"], + siteUrl: process.env.SITE_URL || "https://thirdweb.com", transform: async (config, _path) => { let path = _path; @@ -73,36 +101,14 @@ module.exports = { path = path.replace("deployer.thirdweb.eth", "thirdweb.eth"); } return { + alternateRefs: config.alternateRefs ?? [], + changefreq: config.changefreq, + lastmod: config.autoLastmod ? new Date().toISOString() : undefined, // => this will be exported as http(s):/// loc: path, - changefreq: config.changefreq, priority: config.priority, - lastmod: config.autoLastmod ? new Date().toISOString() : undefined, - alternateRefs: config.alternateRefs ?? [], }; }, - additionalPaths: async (config) => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const FRAMER_PATHS = require("./framer-rewrites"); - const allChains = await fetchChainsFromApi(); - return [ - ...FRAMER_PATHS.map((path) => ({ - loc: path, - changefreq: config.changefreq, - priority: config.priority, - lastmod: config.autoLastmod ? new Date().toISOString() : undefined, - })), - ...allChains.map((chain) => { - return { - loc: `/${chain.slug}`, - changefreq: config.changefreq, - priority: config.priority, - lastmod: config.autoLastmod ? new Date().toISOString() : undefined, - }; - }), - ...(await createSearchRecordSitemaps(config)), - ]; - }, }; /** * @param {{ changefreq?: any; priority?: any; autoLastmod?: any; }} config @@ -125,10 +131,10 @@ async function createSearchRecordSitemaps(config) { parsedLines.map((parsedLine) => { return getSingleChain(parsedLine.chain_id) .then((parsedLineChain) => ({ - loc: `/${parsedLineChain.slug}/${parsedLine.contract_address}`, changefreq: config.changefreq, - priority: config.priority, lastmod: config.autoLastmod ? new Date().toISOString() : undefined, + loc: `/${parsedLineChain.slug}/${parsedLine.contract_address}`, + priority: config.priority, })) .catch(() => null); }), @@ -136,3 +142,19 @@ async function createSearchRecordSitemaps(config) { // filter out any failed requests return chainsForLines.filter(Boolean); } + +async function getFramerXML() { + const framerSiteMapText = await fetch( + "https://landing.thirdweb.com/sitemap.xml", + ).then((res) => res.text()); + + const parser = new XMLParser(); + const xmlObject = parser.parse(framerSiteMapText); + + /** + * @type {Array<{loc: string}>} + */ + const urls = xmlObject.urlset.url; + + return urls; +} diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index f8230298abd..0a3efea2016 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -9,15 +9,20 @@ const ContentSecurityPolicy = ` img-src * data: blob:; media-src * data: blob:; object-src 'none'; - style-src 'self' 'unsafe-inline' vercel.live; + style-src 'self' 'unsafe-inline' vercel.live us.posthog.com; font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com; frame-src * data:; - script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com; + script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com googletagmanager.com us-assets.i.posthog.com edit.framer.com framer.com googletagmanager.com; connect-src * data: blob:; worker-src 'self' blob:; block-all-mixed-content; `; +const EmbedContentSecurityPolicy = ` + ${ContentSecurityPolicy} + frame-ancestors *; +`; + const securityHeaders = [ { key: "X-DNS-Prefetch-Control", @@ -46,90 +51,88 @@ function determineIpfsGateways() { const remotePatterns: RemotePattern[] = []; if (process.env.API_ROUTES_CLIENT_ID) { remotePatterns.push({ - protocol: "https", hostname: `${process.env.API_ROUTES_CLIENT_ID}.ipfscdn.io`, + protocol: "https", }); remotePatterns.push({ - protocol: "https", hostname: `${process.env.API_ROUTES_CLIENT_ID}.thirdwebstorage-staging.com`, + protocol: "https", }); remotePatterns.push({ - protocol: "https", hostname: `${process.env.API_ROUTES_CLIENT_ID}.thirdwebstorage-dev.com`, + protocol: "https", }); } else { // this should only happen in development remotePatterns.push({ - protocol: "https", hostname: "ipfs.io", + protocol: "https", }); } // also add the dashboard clientId ipfs gateway if it is set if (process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID) { remotePatterns.push({ - protocol: "https", hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.ipfscdn.io`, + protocol: "https", }); remotePatterns.push({ - protocol: "https", hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.thirdwebstorage-staging.com`, + protocol: "https", }); remotePatterns.push({ - protocol: "https", hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.thirdwebstorage-dev.com`, + protocol: "https", }); } return remotePatterns; } const SENTRY_OPTIONS: SentryBuildOptions = { + // An auth token is required for uploading source maps. + authToken: process.env.SENTRY_AUTH_TOKEN, + + // Enables automatic instrumentation of Vercel Cron Monitors. + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: false, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, // For all available options, see: // https://github.com/getsentry/sentry-webpack-plugin#options org: "thirdweb-dev", project: "dashboard", - // An auth token is required for uploading source maps. - authToken: process.env.SENTRY_AUTH_TOKEN, // Suppresses source map uploading logs during build silent: true, + + // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) + tunnelRoute: "/err", // For all available options, see: // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ // Upload a larger set of source maps for prettier stack traces (increases build time) widenClientFileUpload: true, - - // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) - tunnelRoute: "/err", - - // Hides source maps from generated client bundles - hideSourceMaps: true, - - // Automatically tree-shake Sentry logger statements to reduce bundle size - disableLogger: true, - - // Enables automatic instrumentation of Vercel Cron Monitors. - // See the following for more information: - // https://docs.sentry.io/product/crons/ - // https://vercel.com/docs/cron-jobs - automaticVercelMonitors: false, }; +// add additional languages to the framer rewrite paths here (english is already included by default) +const FRAMER_ADDITIONAL_LANGUAGES = ["es"]; + const baseNextConfig: NextConfig = { + transpilePackages: ["@workspace/ui"], eslint: { ignoreDuringBuilds: true, }, - productionBrowserSourceMaps: false, experimental: { + serverSourceMaps: false, + taint: true, webpackBuildWorker: true, webpackMemoryOptimizations: true, - serverSourceMaps: false, }, - serverExternalPackages: ["pino-pretty"], async headers() { return [ { - // Apply these headers to all routes in your application. - source: "/(.*)", headers: [ ...securityHeaders, { @@ -137,48 +140,140 @@ const baseNextConfig: NextConfig = { value: "sec-ch-viewport-width", }, ], + // Apply these headers to all routes in your application. + source: "/(.*)", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/widget", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/widget/:path*", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/checkout-widget", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/checkout-widget/:path*", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/swap-widget", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/swap-widget/:path*", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/buy-widget", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/buy-widget/:path*", }, ]; }, + images: { + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", + dangerouslyAllowSVG: true, + remotePatterns: [ + { + hostname: "**.thirdweb.com", + protocol: "https", + }, + ...determineIpfsGateways(), + ], + }, + productionBrowserSourceMaps: false, + reactStrictMode: true, async redirects() { return getRedirects(); }, async rewrites() { return [ { - source: "/thirdweb.eth", + destination: "https://us-assets.i.posthog.com/static/:path*", + source: "/_ph/static/:path*", + }, + { + destination: "https://us.i.posthog.com/:path*", + source: "/_ph/:path*", + }, + { + destination: "https://us.i.posthog.com/decide", + source: "/_ph/decide", + }, + { destination: "/deployer.thirdweb.eth", + source: "/thirdweb.eth", }, { - source: "/thirdweb.eth/:path*", destination: "/deployer.thirdweb.eth/:path*", + source: "/thirdweb.eth/:path*", }, // re-write /home to / (this is so that logged in users will be able to go to /home and NOT be redirected to the logged in app) { + destination: "https://landing.thirdweb.com", source: "/home", - destination: "/", }, - ...FRAMER_PATHS.map((path) => ({ - source: path, - destination: `https://landing.thirdweb.com${path}`, - })), + // flatmap the framer paths for the default language and the additional languages + ...FRAMER_PATHS.flatMap((path) => [ + { + destination: `https://landing.thirdweb.com${path}`, + source: path, + }, + // this is for additional languages + ...FRAMER_ADDITIONAL_LANGUAGES.map((lang) => ({ + destination: `https://landing.thirdweb.com/${lang}${path}`, + source: `/${lang}${path}`, + })), + ]), ]; }, - images: { - dangerouslyAllowSVG: true, - contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", - remotePatterns: [ - { - protocol: "https", - hostname: "**.thirdweb.com", - }, - ...determineIpfsGateways(), - ], - }, - compiler: { - emotion: true, - }, - reactStrictMode: true, }; function getConfig(): NextConfig { @@ -187,36 +282,29 @@ function getConfig(): NextConfig { const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: process.env.ANALYZE === "true", }); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { withPlausibleProxy } = require("next-plausible"); + // eslint-disable-next-line @typescript-eslint/no-var-requires const { withSentryConfig } = require("@sentry/nextjs"); return withBundleAnalyzer( - withPlausibleProxy({ - customDomain: "https://pl.thirdweb.com", - scriptName: "pl", - })( - withSentryConfig( - { - ...baseNextConfig, - // @ts-expect-error - this is a valid option - webpack: (config) => { - if (config.cache) { - config.cache = Object.freeze({ - type: "memory", - }); - } - config.externals.push("pino-pretty"); - config.module = { - ...config.module, - exprContextCritical: false, - }; - // Important: return the modified config - return config; - }, + withSentryConfig( + { + ...baseNextConfig, + // @ts-expect-error - this is a valid option + webpack: (config) => { + if (config.cache) { + config.cache = Object.freeze({ + type: "memory", + }); + } + config.module = { + ...config.module, + exprContextCritical: false, + }; + // Important: return the modified config + return config; }, - SENTRY_OPTIONS, - ), + }, + SENTRY_OPTIONS, ), ); } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c8798e71178..fa4f45ec066 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,144 +1,132 @@ { - "name": "thirdweb-dashboard", - "version": "3.0.0", - "private": true, - "scripts": { - "preinstall": "npx only-allow pnpm", - "dev": "next dev --turbo", - "build": "NODE_OPTIONS=--max-old-space-size=6144 next build", - "start": "next start", - "format": "biome format ./src --write", - "lint": "biome check ./src && knip && eslint ./src", - "fix": "biome check ./src --fix && eslint ./src --fix", - "typecheck": "tsc --noEmit", - "gen:theme-typings": "chakra-cli tokens src/theme/index.ts", - "postinstall": "pnpm run gen:theme-typings", - "postbuild": "next-sitemap", - "build:analyze": "ANALYZE=true pnpm run build", - "knip": "knip", - "playwright": "playwright test", - "update-checkly": "npx checkly deploy", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" - }, "dependencies": { - "@chakra-ui/react": "^2.8.2", - "@chakra-ui/styled-system": "^2.9.2", - "@chakra-ui/theme-tools": "^2.1.2", - "@emotion/react": "11.14.0", - "@emotion/styled": "11.14.0", "@hookform/resolvers": "^3.9.1", "@marsidev/react-turnstile": "^1.1.0", - "@n8tb1t/use-scroll-position": "^2.0.3", - "@radix-ui/react-accordion": "^1.2.2", - "@radix-ui/react-alert-dialog": "^1.1.5", - "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dialog": "1.1.5", - "@radix-ui/react-dropdown-menu": "^2.1.5", - "@radix-ui/react-hover-card": "^1.1.5", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-popover": "^1.1.5", - "@radix-ui/react-progress": "^1.1.1", - "@radix-ui/react-radio-group": "^1.2.2", - "@radix-ui/react-select": "^2.1.5", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-switch": "^1.1.2", - "@radix-ui/react-tooltip": "1.1.7", - "@sentry/nextjs": "8.53.0", - "@shazow/whatsabi": "^0.19.0", - "@tanstack/react-query": "5.66.0", - "@tanstack/react-table": "^8.20.6", + "@number-flow/react": "^0.5.10", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tooltip": "1.2.7", + "@sentry/nextjs": "9.34.0", + "@shazow/whatsabi": "0.22.2", + "@stripe/react-stripe-js": "4.0.2", + "@stripe/stripe-js": "7.9.0", + "@tanstack/react-query": "5.81.5", + "@tanstack/react-table": "^8.21.3", + "@thirdweb-dev/api": "workspace:*", "@thirdweb-dev/service-utils": "workspace:*", - "@vercel/functions": "^1.6.0", - "@vercel/og": "^0.6.5", + "@thirdweb-dev/vault-sdk": "workspace:*", + "@vercel/functions": "2.2.2", + "@vercel/og": "^0.6.8", + "@workspace/ui": "workspace:*", "abitype": "1.0.8", - "chakra-react-select": "^4.7.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "color": "^4.2.3", "compare-versions": "^6.1.0", "date-fns": "4.1.0", + "fast-xml-parser": "^5.2.5", "fetch-event-stream": "0.1.5", - "flat": "^6.0.1", - "framer-motion": "12.0.6", - "fuse.js": "7.0.0", + "fuse.js": "7.1.0", "input-otp": "^1.4.1", - "ioredis": "^5.4.1", + "ioredis": "^5.6.1", "ipaddr.js": "^2.2.0", - "lucide-react": "0.474.0", - "next": "15.1.6", - "next-plausible": "^3.12.4", - "next-themes": "^0.4.4", + "lucide-react": "0.525.0", + "next": "15.3.8", + "next-themes": "^0.4.6", "nextjs-toploader": "^1.6.12", - "openapi-types": "^12.1.3", - "p-limit": "^6.2.0", - "papaparse": "^5.5.2", + "nuqs": "^2.4.3", + "p-limit": "^7.2.0", + "papaparse": "^5.5.3", "pluralize": "^8.0.0", - "posthog-js": "1.67.1", - "qrcode": "^1.5.3", - "react": "19.0.0", + "posthog-js": "1.256.1", + "prettier": "3.6.2", + "react": "19.2.1", "react-children-utilities": "^2.10.0", "react-day-picker": "^8.10.1", - "react-dom": "19.0.0", - "react-dropzone": "^14.3.5", - "react-error-boundary": "^5.0.0", - "react-hook-form": "7.54.2", - "react-markdown": "^9.0.1", + "react-dom": "19.2.1", + "react-dropzone": "^14.3.8", + "react-error-boundary": "6.0.0", + "react-hook-form": "7.55.0", + "react-markdown": "10.1.0", "react-table": "^7.8.0", - "recharts": "2.15.1", - "remark-gfm": "^4.0.0", + "recharts": "2.15.3", + "remark-gfm": "4.0.1", + "responsive-rsc": "0.0.7", "server-only": "^0.0.1", - "shiki": "1.27.0", - "sonner": "^1.7.4", + "shiki": "3.12.0", + "sonner": "2.0.6", "spdx-correct": "^3.2.0", - "swagger-ui-react": "^5.18.3", + "stripe": "17.7.0", + "swagger-ui-react": "^5.24.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "thirdweb": "workspace:*", "tiny-invariant": "^1.3.3", - "use-debounce": "^10.0.4", - "zod": "3.24.1" + "use-debounce": "^10.0.5", + "vaul": "^1.1.2", + "zod": "3.25.75" }, "devDependencies": { - "@chakra-ui/cli": "^2.4.1", - "@chromatic-com/storybook": "3.2.4", - "@next/bundle-analyzer": "15.1.6", - "@next/eslint-plugin-next": "15.1.6", - "@playwright/test": "1.50.1", - "@storybook/addon-essentials": "8.5.2", - "@storybook/addon-interactions": "8.5.2", - "@storybook/addon-links": "8.5.2", - "@storybook/addon-onboarding": "8.5.2", - "@storybook/addon-viewport": "8.5.2", - "@storybook/blocks": "8.5.2", - "@storybook/nextjs": "8.5.2", - "@storybook/react": "8.5.2", - "@storybook/test": "8.5.2", + "@biomejs/biome": "2.0.6", + "@chromatic-com/storybook": "4.0.1", + "@next/bundle-analyzer": "15.3.8", + "@next/eslint-plugin-next": "15.3.8", + "@storybook/addon-docs": "9.0.15", + "@storybook/addon-links": "9.0.15", + "@storybook/addon-onboarding": "9.0.15", + "@storybook/nextjs": "9.0.15", "@types/color": "4.2.0", - "@types/node": "22.13.0", - "@types/papaparse": "^5.3.15", + "@types/node": "22.14.1", + "@types/papaparse": "^5.3.16", "@types/pluralize": "^0.0.33", - "@types/qrcode": "^1.5.5", - "@types/react": "19.0.8", - "@types/react-dom": "19.0.3", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", "@types/react-table": "^7.7.20", "@types/spdx-correct": "^3.1.3", - "@types/swagger-ui-react": "^4.19.0", + "@types/swagger-ui-react": "^5.18.0", "@typescript-eslint/eslint-plugin": "7.14.1", "@typescript-eslint/parser": "7.14.1", - "autoprefixer": "^10.4.19", - "checkly": "^4.19.1", + "autoprefixer": "^10.4.21", "eslint": "8.57.0", "eslint-config-biome": "1.9.4", - "eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124", - "eslint-plugin-storybook": "^0.11.1", - "knip": "5.43.6", + "eslint-plugin-react-compiler": "19.1.0-rc.2", + "eslint-plugin-storybook": "9.0.15", + "knip": "5.60.2", "next-sitemap": "^4.2.3", - "postcss": "8.5.1", - "storybook": "8.5.2", + "postcss": "8.5.6", + "storybook": "9.0.15", "tailwindcss": "3.4.17", - "typescript": "5.7.3" - } + "typescript": "5.8.3" + }, + "name": "thirdweb-dashboard", + "private": true, + "scripts": { + "build": "NODE_OPTIONS=--max-old-space-size=6144 next build", + "build-storybook": "storybook build", + "build:analyze": "ANALYZE=true pnpm run build", + "dev": "next dev --turbopack", + "fix": "biome check ./src --fix && eslint ./src --fix", + "format": "biome format ./src --write", + "knip": "knip", + "lint": "biome check ./src && knip && eslint ./src", + "postbuild": "next-sitemap", + "preinstall": "npx only-allow pnpm", + "start": "next start", + "storybook": "storybook dev -p 6006", + "typecheck": "tsc --noEmit" + }, + "version": "3.0.0" } diff --git a/apps/dashboard/playwright.config.ts b/apps/dashboard/playwright.config.ts deleted file mode 100644 index ca9c4b4b6ee..00000000000 --- a/apps/dashboard/playwright.config.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: "./tests", - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000", - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - - { - name: "firefox", - use: { ...devices["Desktop Firefox"] }, - }, - - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - webServer: process.env.CI - ? undefined - : { - command: "pnpm run start", - url: "http://127.0.0.1:3000", - reuseExistingServer: !process.env.CI, - }, -}); diff --git a/apps/dashboard/postcss.config.mjs b/apps/dashboard/postcss.config.mjs new file mode 100644 index 00000000000..1f1ca64b29b --- /dev/null +++ b/apps/dashboard/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from "@workspace/ui/postcss.config"; diff --git a/apps/dashboard/public/assets/add-contract/nft.png b/apps/dashboard/public/assets/add-contract/nft.png deleted file mode 100644 index 6e94867c519..00000000000 Binary files a/apps/dashboard/public/assets/add-contract/nft.png and /dev/null differ diff --git a/apps/dashboard/public/assets/community/gallery/1.png b/apps/dashboard/public/assets/community/gallery/1.png deleted file mode 100644 index 6882d2b14c1..00000000000 Binary files a/apps/dashboard/public/assets/community/gallery/1.png and /dev/null differ diff --git a/apps/dashboard/public/assets/community/gallery/2.png b/apps/dashboard/public/assets/community/gallery/2.png deleted file mode 100644 index e19b585e608..00000000000 Binary files a/apps/dashboard/public/assets/community/gallery/2.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/confetti.gif b/apps/dashboard/public/assets/dashboard/confetti.gif deleted file mode 100644 index bd892c18cdd..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/confetti.gif and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/contracts/deploy.png b/apps/dashboard/public/assets/dashboard/contracts/deploy.png deleted file mode 100644 index 4d1f9de6374..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/contracts/deploy.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/deploy.png b/apps/dashboard/public/assets/dashboard/deploy.png deleted file mode 100644 index 4a4a67b04eb..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/deploy.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/features/analytics.png b/apps/dashboard/public/assets/dashboard/features/analytics.png deleted file mode 100644 index dfec42ebcb1..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/features/analytics.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/op-sponsorship-form.png b/apps/dashboard/public/assets/dashboard/op-sponsorship-form.png deleted file mode 100644 index b46cbbb3e66..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/op-sponsorship-form.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/op-sponsorship-nft.png b/apps/dashboard/public/assets/dashboard/op-sponsorship-nft.png deleted file mode 100644 index 2f104afbf80..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/op-sponsorship-nft.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/publish.png b/apps/dashboard/public/assets/dashboard/publish.png deleted file mode 100644 index c46a3c21ba3..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/publish.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/wallets/smart-wallet.png b/apps/dashboard/public/assets/dashboard/wallets/smart-wallet.png deleted file mode 100644 index 75eb6e8526d..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/wallets/smart-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dashboard/wallets/wallet-sdk.png b/apps/dashboard/public/assets/dashboard/wallets/wallet-sdk.png deleted file mode 100644 index b4f600459e0..00000000000 Binary files a/apps/dashboard/public/assets/dashboard/wallets/wallet-sdk.png and /dev/null differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/monitoring-dark.png b/apps/dashboard/public/assets/dedicated-relayer/monitoring-dark.png new file mode 100644 index 00000000000..a55074a25c4 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/monitoring-dark.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/monitoring-light.png b/apps/dashboard/public/assets/dedicated-relayer/monitoring-light.png new file mode 100644 index 00000000000..f20dc8f646a Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/monitoring-light.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/no-config-dark.png b/apps/dashboard/public/assets/dedicated-relayer/no-config-dark.png new file mode 100644 index 00000000000..376c199b139 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/no-config-dark.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/no-config-light.png b/apps/dashboard/public/assets/dedicated-relayer/no-config-light.png new file mode 100644 index 00000000000..d509a4c7718 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/no-config-light.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/server-wallet-dark.png b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-dark.png new file mode 100644 index 00000000000..09ad42b1b91 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-dark.png differ diff --git a/apps/dashboard/public/assets/dedicated-relayer/server-wallet-light.png b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-light.png new file mode 100644 index 00000000000..aec90e46796 Binary files /dev/null and b/apps/dashboard/public/assets/dedicated-relayer/server-wallet-light.png differ diff --git a/apps/dashboard/public/assets/engine/empty-state-header.png b/apps/dashboard/public/assets/engine/empty-state-header.png deleted file mode 100644 index 0454a73cde0..00000000000 Binary files a/apps/dashboard/public/assets/engine/empty-state-header.png and /dev/null differ diff --git a/apps/dashboard/public/assets/examples/Thirdweb_Terms_of_Service.pdf b/apps/dashboard/public/assets/examples/Thirdweb_Terms_of_Service.pdf deleted file mode 100644 index 0447d9ac7e7..00000000000 Binary files a/apps/dashboard/public/assets/examples/Thirdweb_Terms_of_Service.pdf and /dev/null differ diff --git a/apps/dashboard/public/assets/examples/example-with-ipfs.csv b/apps/dashboard/public/assets/examples/example-with-ipfs.csv deleted file mode 100644 index d46ea39a909..00000000000 --- a/apps/dashboard/public/assets/examples/example-with-ipfs.csv +++ /dev/null @@ -1,2 +0,0 @@ -name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added -Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,https://thirdweb.com,#0098EE,,every,row,is a ,property diff --git a/apps/dashboard/public/assets/examples/example-with-maps.csv b/apps/dashboard/public/assets/examples/example-with-maps.csv deleted file mode 100644 index fd1879f1422..00000000000 --- a/apps/dashboard/public/assets/examples/example-with-maps.csv +++ /dev/null @@ -1,4 +0,0 @@ -name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added -Token 0 Name,Token 0 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property -Token 1 Name,Token 1 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property -Token 2 Name,Token 2 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property diff --git a/apps/dashboard/public/assets/examples/example.csv b/apps/dashboard/public/assets/examples/example.csv deleted file mode 100644 index 60232fb7f37..00000000000 --- a/apps/dashboard/public/assets/examples/example.csv +++ /dev/null @@ -1,2 +0,0 @@ -name,description,external_url,background_color,youtube_url,additional,properties,can be,added -Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property \ No newline at end of file diff --git a/apps/dashboard/public/assets/examples/snapshot-with-maxclaimable.csv b/apps/dashboard/public/assets/examples/snapshot-with-maxclaimable.csv deleted file mode 100644 index 6db267cebd0..00000000000 --- a/apps/dashboard/public/assets/examples/snapshot-with-maxclaimable.csv +++ /dev/null @@ -1,3 +0,0 @@ -address,maxClaimable -0x0000000000000000000000000000000000000000,2 -0x000000000000000000000000000000000000dEaD,5 \ No newline at end of file diff --git a/apps/dashboard/public/assets/examples/snapshot-with-overrides.csv b/apps/dashboard/public/assets/examples/snapshot-with-overrides.csv deleted file mode 100644 index 29c581674f2..00000000000 --- a/apps/dashboard/public/assets/examples/snapshot-with-overrides.csv +++ /dev/null @@ -1,3 +0,0 @@ -address,maxClaimable,price,currencyAddress -0x0000000000000000000000000000000000000000,2,0.1,0x0000000000000000000000000000000000000000 -0x000000000000000000000000000000000000dEaD,5,2.5,0x0000000000000000000000000000000000000000 \ No newline at end of file diff --git a/apps/dashboard/public/assets/examples/snapshot.csv b/apps/dashboard/public/assets/examples/snapshot.csv deleted file mode 100644 index 7a2c24783ce..00000000000 --- a/apps/dashboard/public/assets/examples/snapshot.csv +++ /dev/null @@ -1,3 +0,0 @@ -address -0x0000000000000000000000000000000000000000 -0x000000000000000000000000000000000000dEaD \ No newline at end of file diff --git a/apps/dashboard/public/assets/examples/thirdweb_Privacy_Policy_May_2022.pdf b/apps/dashboard/public/assets/examples/thirdweb_Privacy_Policy_May_2022.pdf deleted file mode 100644 index 3f1d41a6c54..00000000000 Binary files a/apps/dashboard/public/assets/examples/thirdweb_Privacy_Policy_May_2022.pdf and /dev/null differ diff --git a/apps/dashboard/public/assets/grant/superchain/hero.png b/apps/dashboard/public/assets/grant/superchain/hero.png deleted file mode 100644 index 8fb30967f49..00000000000 Binary files a/apps/dashboard/public/assets/grant/superchain/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/illustrations/wallet.png b/apps/dashboard/public/assets/illustrations/wallet.png deleted file mode 100644 index 377d091ecf1..00000000000 Binary files a/apps/dashboard/public/assets/illustrations/wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/analytics.png b/apps/dashboard/public/assets/landingpage/analytics.png deleted file mode 100644 index 8d8641ce266..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/analytics.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/check.svg b/apps/dashboard/public/assets/landingpage/check.svg deleted file mode 100644 index 0fb836eb0b3..00000000000 --- a/apps/dashboard/public/assets/landingpage/check.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/dashboard/public/assets/landingpage/connect-icon.png b/apps/dashboard/public/assets/landingpage/connect-icon.png deleted file mode 100644 index b9738457f4e..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/connect-icon.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/contracts-icon.png b/apps/dashboard/public/assets/landingpage/contracts-icon.png deleted file mode 100644 index be595d9b583..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/contracts-icon.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/contracts.png b/apps/dashboard/public/assets/landingpage/contracts.png deleted file mode 100644 index 7ae9899b17d..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/account-abstraction.png b/apps/dashboard/public/assets/landingpage/desktop/account-abstraction.png deleted file mode 100644 index 0488969290a..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/account-abstraction.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/analytics.png b/apps/dashboard/public/assets/landingpage/desktop/analytics.png deleted file mode 100644 index 16502b64c61..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/analytics.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/auth.png b/apps/dashboard/public/assets/landingpage/desktop/auth.png deleted file mode 100644 index 8ffac4fce13..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/cross-platform.png b/apps/dashboard/public/assets/landingpage/desktop/cross-platform.png deleted file mode 100644 index 4b1b9080dcf..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/cross-platform.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/enterprise-security.png b/apps/dashboard/public/assets/landingpage/desktop/enterprise-security.png deleted file mode 100644 index 3f719f13995..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/enterprise-security.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/guest.png b/apps/dashboard/public/assets/landingpage/desktop/guest.png deleted file mode 100644 index 1c0f22c93bb..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/guest.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/magic.png b/apps/dashboard/public/assets/landingpage/desktop/magic.png deleted file mode 100644 index 42fae280795..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/magic.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/onboard.png b/apps/dashboard/public/assets/landingpage/desktop/onboard.png deleted file mode 100644 index 0fa55bf6167..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/onboard.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/powerful.png b/apps/dashboard/public/assets/landingpage/desktop/powerful.png deleted file mode 100644 index 5c1bd1e8515..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/powerful.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/siwe.png b/apps/dashboard/public/assets/landingpage/desktop/siwe.png deleted file mode 100644 index 66f75c43780..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/siwe.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/desktop/wallet.png b/apps/dashboard/public/assets/landingpage/desktop/wallet.png deleted file mode 100644 index 5bafdbd83b4..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/desktop/wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/engine-icon.png b/apps/dashboard/public/assets/landingpage/engine-icon.png deleted file mode 100644 index bb83199f0a6..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/engine-icon.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile-hero.png b/apps/dashboard/public/assets/landingpage/mobile-hero.png deleted file mode 100644 index da011358ad3..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile-hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/account-abstraction.png b/apps/dashboard/public/assets/landingpage/mobile/account-abstraction.png deleted file mode 100644 index 65162fd0e32..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/account-abstraction.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/analytics.png b/apps/dashboard/public/assets/landingpage/mobile/analytics.png deleted file mode 100644 index ae364c98864..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/analytics.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/auth.png b/apps/dashboard/public/assets/landingpage/mobile/auth.png deleted file mode 100644 index 334376ab90a..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/backend.png b/apps/dashboard/public/assets/landingpage/mobile/backend.png deleted file mode 100644 index 1801db186d9..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/backend.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/cross-platform.png b/apps/dashboard/public/assets/landingpage/mobile/cross-platform.png deleted file mode 100644 index e8e0a5c771d..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/cross-platform.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/enterprise-security.png b/apps/dashboard/public/assets/landingpage/mobile/enterprise-security.png deleted file mode 100644 index fd8bb363301..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/enterprise-security.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/guest.png b/apps/dashboard/public/assets/landingpage/mobile/guest.png deleted file mode 100644 index e6124f568f9..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/guest.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/magic.png b/apps/dashboard/public/assets/landingpage/mobile/magic.png deleted file mode 100644 index d9affbab7ba..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/magic.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/onboard.png b/apps/dashboard/public/assets/landingpage/mobile/onboard.png deleted file mode 100644 index 0019e14852a..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/onboard.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/powerful.png b/apps/dashboard/public/assets/landingpage/mobile/powerful.png deleted file mode 100644 index 1acf27c90c4..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/powerful.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/siwe.png b/apps/dashboard/public/assets/landingpage/mobile/siwe.png deleted file mode 100644 index 31534c87a0d..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/siwe.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/mobile/wallet.png b/apps/dashboard/public/assets/landingpage/mobile/wallet.png deleted file mode 100644 index fb071535aa8..00000000000 Binary files a/apps/dashboard/public/assets/landingpage/mobile/wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/landingpage/verified.svg b/apps/dashboard/public/assets/landingpage/verified.svg deleted file mode 100644 index 35cbada67db..00000000000 --- a/apps/dashboard/public/assets/landingpage/verified.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/dashboard/public/assets/learn/hero.png b/apps/dashboard/public/assets/learn/hero.png deleted file mode 100644 index ea843f6d132..00000000000 Binary files a/apps/dashboard/public/assets/learn/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/nebula/favicon.ico b/apps/dashboard/public/assets/nebula/favicon.ico new file mode 100644 index 00000000000..1cea9182e46 Binary files /dev/null and b/apps/dashboard/public/assets/nebula/favicon.ico differ diff --git a/apps/dashboard/public/assets/network-pages/solana/hero.png b/apps/dashboard/public/assets/network-pages/solana/hero.png deleted file mode 100644 index ecab34db2a4..00000000000 Binary files a/apps/dashboard/public/assets/network-pages/solana/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/auth.png b/apps/dashboard/public/assets/og-image/auth.png deleted file mode 100644 index c4b88b88e62..00000000000 Binary files a/apps/dashboard/public/assets/og-image/auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/connect.png b/apps/dashboard/public/assets/og-image/connect.png deleted file mode 100644 index a515ca5f8c7..00000000000 Binary files a/apps/dashboard/public/assets/og-image/connect.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/contracts.png b/apps/dashboard/public/assets/og-image/contracts.png deleted file mode 100644 index 20df8d9933e..00000000000 Binary files a/apps/dashboard/public/assets/og-image/contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/deploy.png b/apps/dashboard/public/assets/og-image/deploy.png deleted file mode 100644 index 2d13da19f1a..00000000000 Binary files a/apps/dashboard/public/assets/og-image/deploy.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/embedded-wallets.png b/apps/dashboard/public/assets/og-image/embedded-wallets.png deleted file mode 100644 index f5fadd93b41..00000000000 Binary files a/apps/dashboard/public/assets/og-image/embedded-wallets.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/engine.png b/apps/dashboard/public/assets/og-image/engine.png deleted file mode 100644 index 1455d223436..00000000000 Binary files a/apps/dashboard/public/assets/og-image/engine.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/publish.png b/apps/dashboard/public/assets/og-image/publish.png deleted file mode 100644 index 27046b0a8e0..00000000000 Binary files a/apps/dashboard/public/assets/og-image/publish.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/rpc-edge.png b/apps/dashboard/public/assets/og-image/rpc-edge.png deleted file mode 100644 index 21a4a33f1a2..00000000000 Binary files a/apps/dashboard/public/assets/og-image/rpc-edge.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/sdk.png b/apps/dashboard/public/assets/og-image/sdk.png deleted file mode 100644 index 57d458c1555..00000000000 Binary files a/apps/dashboard/public/assets/og-image/sdk.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/smart-contracts.png b/apps/dashboard/public/assets/og-image/smart-contracts.png deleted file mode 100644 index f4cf24270aa..00000000000 Binary files a/apps/dashboard/public/assets/og-image/smart-contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/smart-wallet.png b/apps/dashboard/public/assets/og-image/smart-wallet.png deleted file mode 100644 index f67e05e06dd..00000000000 Binary files a/apps/dashboard/public/assets/og-image/smart-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/storage.png b/apps/dashboard/public/assets/og-image/storage.png deleted file mode 100644 index b0b75ee3fdd..00000000000 Binary files a/apps/dashboard/public/assets/og-image/storage.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/superchain.png b/apps/dashboard/public/assets/og-image/superchain.png deleted file mode 100644 index fe65e23752d..00000000000 Binary files a/apps/dashboard/public/assets/og-image/superchain.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/ui-components.png b/apps/dashboard/public/assets/og-image/ui-components.png deleted file mode 100644 index a5e47ec437f..00000000000 Binary files a/apps/dashboard/public/assets/og-image/ui-components.png and /dev/null differ diff --git a/apps/dashboard/public/assets/og-image/wallet-sdk.png b/apps/dashboard/public/assets/og-image/wallet-sdk.png deleted file mode 100644 index a631baaa1cf..00000000000 Binary files a/apps/dashboard/public/assets/og-image/wallet-sdk.png and /dev/null differ diff --git a/apps/dashboard/public/assets/partner-pages/shopify/hero.png b/apps/dashboard/public/assets/partner-pages/shopify/hero.png deleted file mode 100644 index eb6a5ff66aa..00000000000 Binary files a/apps/dashboard/public/assets/partner-pages/shopify/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/partners/paper.png b/apps/dashboard/public/assets/partners/paper.png deleted file mode 100644 index 2bb0f408dc3..00000000000 Binary files a/apps/dashboard/public/assets/partners/paper.png and /dev/null differ diff --git a/apps/dashboard/public/assets/pay/general-pay.png b/apps/dashboard/public/assets/pay/general-pay.png new file mode 100644 index 00000000000..8729bb7c2fb Binary files /dev/null and b/apps/dashboard/public/assets/pay/general-pay.png differ diff --git a/apps/dashboard/public/assets/product-icons/auth.png b/apps/dashboard/public/assets/product-icons/auth.png deleted file mode 100644 index 90f4be0103e..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/cli.svg b/apps/dashboard/public/assets/product-icons/cli.svg deleted file mode 100644 index 4a380c2dca2..00000000000 --- a/apps/dashboard/public/assets/product-icons/cli.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-icons/contracts.png b/apps/dashboard/public/assets/product-icons/contracts.png deleted file mode 100644 index be595d9b583..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/dashboard.svg b/apps/dashboard/public/assets/product-icons/dashboard.svg deleted file mode 100644 index c4cae3cf0dc..00000000000 --- a/apps/dashboard/public/assets/product-icons/dashboard.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-icons/deploy.png b/apps/dashboard/public/assets/product-icons/deploy.png deleted file mode 100644 index f6ed6e4bd7f..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/deploy.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/embedded-wallet.png b/apps/dashboard/public/assets/product-icons/embedded-wallet.png deleted file mode 100644 index 24985d03d03..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/embedded-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/engine.png b/apps/dashboard/public/assets/product-icons/engine.png deleted file mode 100644 index bb83199f0a6..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/engine.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/extensions.png b/apps/dashboard/public/assets/product-icons/extensions.png deleted file mode 100644 index bd4d80a9cc5..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/extensions.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/infrastructure.png b/apps/dashboard/public/assets/product-icons/infrastructure.png deleted file mode 100644 index b81ff98f2dd..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/infrastructure.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/net.svg b/apps/dashboard/public/assets/product-icons/net.svg deleted file mode 100644 index d204a090424..00000000000 --- a/apps/dashboard/public/assets/product-icons/net.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/apps/dashboard/public/assets/product-icons/publish.png b/apps/dashboard/public/assets/product-icons/publish.png deleted file mode 100644 index 5d6220afd26..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/publish.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/react.svg b/apps/dashboard/public/assets/product-icons/react.svg deleted file mode 100644 index 21e3e4033f5..00000000000 --- a/apps/dashboard/public/assets/product-icons/react.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/dashboard/public/assets/product-icons/rpc-edge.png b/apps/dashboard/public/assets/product-icons/rpc-edge.png deleted file mode 100644 index cbb0c9b6b9c..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/rpc-edge.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/smart-contracts.png b/apps/dashboard/public/assets/product-icons/smart-contracts.png deleted file mode 100644 index 1f294631d4d..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/smart-contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/smart-wallet.png b/apps/dashboard/public/assets/product-icons/smart-wallet.png deleted file mode 100644 index e998cb819b3..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/smart-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/solidity.svg b/apps/dashboard/public/assets/product-icons/solidity.svg deleted file mode 100644 index cc6494df80b..00000000000 --- a/apps/dashboard/public/assets/product-icons/solidity.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/dashboard/public/assets/product-icons/storage.png b/apps/dashboard/public/assets/product-icons/storage.png deleted file mode 100644 index 6a3fa00818a..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/storage.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/typescript.svg b/apps/dashboard/public/assets/product-icons/typescript.svg deleted file mode 100644 index 78843d600e2..00000000000 --- a/apps/dashboard/public/assets/product-icons/typescript.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-icons/ui-components.png b/apps/dashboard/public/assets/product-icons/ui-components.png deleted file mode 100644 index e6c11fab6a2..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/ui-components.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/unity.svg b/apps/dashboard/public/assets/product-icons/unity.svg deleted file mode 100644 index 07b65e93fb7..00000000000 --- a/apps/dashboard/public/assets/product-icons/unity.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-icons/wallet-sdk.png b/apps/dashboard/public/assets/product-icons/wallet-sdk.png deleted file mode 100644 index d4ff3aa6e45..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/wallet-sdk.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-icons/wallets.png b/apps/dashboard/public/assets/product-icons/wallets.png deleted file mode 100644 index b9738457f4e..00000000000 Binary files a/apps/dashboard/public/assets/product-icons/wallets.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-build.svg b/apps/dashboard/public/assets/product-pages-icons/contracts/icon-build.svg deleted file mode 100644 index fcae9f3b4df..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-build.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-secure.svg b/apps/dashboard/public/assets/product-pages-icons/contracts/icon-secure.svg deleted file mode 100644 index 9ccba2a1147..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-secure.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-simple-click.svg b/apps/dashboard/public/assets/product-pages-icons/contracts/icon-simple-click.svg deleted file mode 100644 index 4b94d719292..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-simple-click.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-verified.svg b/apps/dashboard/public/assets/product-pages-icons/contracts/icon-verified.svg deleted file mode 100644 index 957dc80c3e5..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/contracts/icon-verified.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/infra/icon-efficient.svg b/apps/dashboard/public/assets/product-pages-icons/infra/icon-efficient.svg deleted file mode 100644 index 06d1bba2be7..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/infra/icon-efficient.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/infra/icon-verified.svg b/apps/dashboard/public/assets/product-pages-icons/infra/icon-verified.svg deleted file mode 100644 index 921be140f75..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/infra/icon-verified.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/payments/icon-efficient.svg b/apps/dashboard/public/assets/product-pages-icons/payments/icon-efficient.svg deleted file mode 100644 index 373820f82fd..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/payments/icon-efficient.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/payments/icon-private.svg b/apps/dashboard/public/assets/product-pages-icons/payments/icon-private.svg deleted file mode 100644 index b149180cc3a..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/payments/icon-private.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/payments/icon-secure.svg b/apps/dashboard/public/assets/product-pages-icons/payments/icon-secure.svg deleted file mode 100644 index b5d790946b0..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/payments/icon-secure.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/payments/icon-simple-click.svg b/apps/dashboard/public/assets/product-pages-icons/payments/icon-simple-click.svg deleted file mode 100644 index 6117b708a0a..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/payments/icon-simple-click.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-build.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-build.svg deleted file mode 100644 index 66c54aca38f..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-build.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-data-check.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-data-check.svg deleted file mode 100644 index 0874f623be5..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-data-check.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-efficient.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-efficient.svg deleted file mode 100644 index 9c34541ef37..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-efficient.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-private.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-private.svg deleted file mode 100644 index 1544c86ac4f..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-private.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-secure.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-secure.svg deleted file mode 100644 index c29920f8fad..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-secure.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-simple-click.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-simple-click.svg deleted file mode 100644 index 6fec34fbb77..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-simple-click.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-verified.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-verified.svg deleted file mode 100644 index 35dc3a1efed..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-verified.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-wallet-management.svg b/apps/dashboard/public/assets/product-pages-icons/wallets/icon-wallet-management.svg deleted file mode 100644 index a4d51648952..00000000000 --- a/apps/dashboard/public/assets/product-pages-icons/wallets/icon-wallet-management.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages/authentication/auth.png b/apps/dashboard/public/assets/product-pages/authentication/auth.png deleted file mode 100644 index a869b1ba014..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/authentication/auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/connect/account-abstraction.png b/apps/dashboard/public/assets/product-pages/connect/account-abstraction.png deleted file mode 100644 index 4c1af032df1..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/connect/account-abstraction.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/connect/connect.png b/apps/dashboard/public/assets/product-pages/connect/connect.png deleted file mode 100644 index a8095e228f4..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/connect/connect.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/connect/get-started.png b/apps/dashboard/public/assets/product-pages/connect/get-started.png deleted file mode 100644 index 64ed7ee80d4..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/connect/get-started.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/contracts/desktop-hero.png b/apps/dashboard/public/assets/product-pages/contracts/desktop-hero.png deleted file mode 100644 index 197f8c970ad..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/contracts/desktop-hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/contracts/mobile-hero.png b/apps/dashboard/public/assets/product-pages/contracts/mobile-hero.png deleted file mode 100644 index 4f10af208d3..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/contracts/mobile-hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/dashboard/hero.png b/apps/dashboard/public/assets/product-pages/dashboard/hero.png deleted file mode 100644 index f77b7effacb..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/dashboard/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/deploy/hero.png b/apps/dashboard/public/assets/product-pages/deploy/hero.png deleted file mode 100644 index 2fcedde21c5..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/deploy/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/embedded-wallets/auth.png b/apps/dashboard/public/assets/product-pages/embedded-wallets/auth.png deleted file mode 100644 index 2f1778f5857..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/embedded-wallets/auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/embedded-wallets/cross-platform.png b/apps/dashboard/public/assets/product-pages/embedded-wallets/cross-platform.png deleted file mode 100644 index ba5f2d78600..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/embedded-wallets/cross-platform.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/embedded-wallets/embedded-wallet.png b/apps/dashboard/public/assets/product-pages/embedded-wallets/embedded-wallet.png deleted file mode 100644 index fd4d4b9ddc7..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/embedded-wallets/embedded-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/embedded-wallets/paper.png b/apps/dashboard/public/assets/product-pages/embedded-wallets/paper.png deleted file mode 100644 index a9da6a8437c..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/embedded-wallets/paper.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/embedded-wallets/seamless.png b/apps/dashboard/public/assets/product-pages/embedded-wallets/seamless.png deleted file mode 100644 index 44056bc7533..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/embedded-wallets/seamless.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/engine/account-abstraction.png b/apps/dashboard/public/assets/product-pages/engine/account-abstraction.png deleted file mode 100644 index 2b59e07c138..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/engine/account-abstraction.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/engine/mobile-hero.png b/apps/dashboard/public/assets/product-pages/engine/mobile-hero.png deleted file mode 100644 index e63a0453d46..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/engine/mobile-hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/engine/smart-contracts.png b/apps/dashboard/public/assets/product-pages/engine/smart-contracts.png deleted file mode 100644 index 2f7eaae86a9..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/engine/smart-contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/extensions/hero.png b/apps/dashboard/public/assets/product-pages/extensions/hero.png deleted file mode 100644 index 928ab3e396c..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/extensions/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/hero/desktop-hero-auth.png b/apps/dashboard/public/assets/product-pages/hero/desktop-hero-auth.png deleted file mode 100644 index 38887c5192e..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/hero/desktop-hero-auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/hero/desktop-hero-embedded-wallets.png b/apps/dashboard/public/assets/product-pages/hero/desktop-hero-embedded-wallets.png deleted file mode 100644 index 15cd6299577..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/hero/desktop-hero-embedded-wallets.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/hero/mobile-hero-auth.png b/apps/dashboard/public/assets/product-pages/hero/mobile-hero-auth.png deleted file mode 100644 index 7590338852e..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/hero/mobile-hero-auth.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/hero/mobile-hero-embedded-wallets.png b/apps/dashboard/public/assets/product-pages/hero/mobile-hero-embedded-wallets.png deleted file mode 100644 index 12890f40938..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/hero/mobile-hero-embedded-wallets.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/mission/icon-simple-click.svg b/apps/dashboard/public/assets/product-pages/mission/icon-simple-click.svg deleted file mode 100644 index 5741eee7182..00000000000 --- a/apps/dashboard/public/assets/product-pages/mission/icon-simple-click.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/product-pages/pre-builts/hero.png b/apps/dashboard/public/assets/product-pages/pre-builts/hero.png deleted file mode 100644 index 80f710e303d..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/pre-builts/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/publish/hero.png b/apps/dashboard/public/assets/product-pages/publish/hero.png deleted file mode 100644 index e1491f4cc10..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/publish/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/sdk/hero.png b/apps/dashboard/public/assets/product-pages/sdk/hero.png deleted file mode 100644 index ead632db97b..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/sdk/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/account-abstraction.png b/apps/dashboard/public/assets/product-pages/smart-wallet/account-abstraction.png deleted file mode 100644 index ee635e97211..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/account-abstraction.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/batch-txns.png b/apps/dashboard/public/assets/product-pages/smart-wallet/batch-txns.png deleted file mode 100644 index d973202790d..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/batch-txns.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/dashboard.png b/apps/dashboard/public/assets/product-pages/smart-wallet/dashboard.png deleted file mode 100644 index 2db74c7437d..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/dashboard.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/desktop-hero.png b/apps/dashboard/public/assets/product-pages/smart-wallet/desktop-hero.png deleted file mode 100644 index 3f920f6e946..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/desktop-hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/full-programmability.png b/apps/dashboard/public/assets/product-pages/smart-wallet/full-programmability.png deleted file mode 100644 index 6fcaffb02e2..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/full-programmability.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/get-started.png b/apps/dashboard/public/assets/product-pages/smart-wallet/get-started.png deleted file mode 100644 index bd8e13a8dd4..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/get-started.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/invisible-wallet.png b/apps/dashboard/public/assets/product-pages/smart-wallet/invisible-wallet.png deleted file mode 100644 index f32e9c58273..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/invisible-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/managed-infrastructure.png b/apps/dashboard/public/assets/product-pages/smart-wallet/managed-infrastructure.png deleted file mode 100644 index 0b103a0bf58..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/managed-infrastructure.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/mobile-hero.png b/apps/dashboard/public/assets/product-pages/smart-wallet/mobile-hero.png deleted file mode 100644 index d115d0bef2e..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/mobile-hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/pair-any-wallet.png b/apps/dashboard/public/assets/product-pages/smart-wallet/pair-any-wallet.png deleted file mode 100644 index de478e56046..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/pair-any-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/smart-contracts.png b/apps/dashboard/public/assets/product-pages/smart-wallet/smart-contracts.png deleted file mode 100644 index e2f0cfc0786..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/smart-contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/ui-components.png b/apps/dashboard/public/assets/product-pages/smart-wallet/ui-components.png deleted file mode 100644 index d003603c7c7..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/ui-components.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/smart-wallet/which-contract.png b/apps/dashboard/public/assets/product-pages/smart-wallet/which-contract.png deleted file mode 100644 index 45791631eca..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/smart-wallet/which-contract.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/storage/hero.png b/apps/dashboard/public/assets/product-pages/storage/hero.png deleted file mode 100644 index 1ce2ac952be..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/storage/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/ui-components/hero.png b/apps/dashboard/public/assets/product-pages/ui-components/hero.png deleted file mode 100644 index cf87071feea..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/ui-components/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/wallet-sdk/hero.png b/apps/dashboard/public/assets/product-pages/wallet-sdk/hero.png deleted file mode 100644 index 86d9a2376f8..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/wallet-sdk/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/product-pages/wallet-sdk/smart-wallet.png b/apps/dashboard/public/assets/product-pages/wallet-sdk/smart-wallet.png deleted file mode 100644 index 0aedbfdf312..00000000000 Binary files a/apps/dashboard/public/assets/product-pages/wallet-sdk/smart-wallet.png and /dev/null differ diff --git a/apps/dashboard/public/assets/solutions-icons/chains.svg b/apps/dashboard/public/assets/solutions-icons/chains.svg deleted file mode 100644 index 7eb0632b377..00000000000 --- a/apps/dashboard/public/assets/solutions-icons/chains.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/assets/solutions-icons/gaming.svg b/apps/dashboard/public/assets/solutions-icons/gaming.svg deleted file mode 100644 index d1084004b22..00000000000 --- a/apps/dashboard/public/assets/solutions-icons/gaming.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/assets/solutions-pages/commerce/hero.png b/apps/dashboard/public/assets/solutions-pages/commerce/hero.png deleted file mode 100644 index 9c8e342b7bf..00000000000 Binary files a/apps/dashboard/public/assets/solutions-pages/commerce/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/solutions-pages/gaming/hero.png b/apps/dashboard/public/assets/solutions-pages/gaming/hero.png deleted file mode 100644 index baa78bdf06e..00000000000 Binary files a/apps/dashboard/public/assets/solutions-pages/gaming/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/solutions-pages/icons/icon-build.svg b/apps/dashboard/public/assets/solutions-pages/icons/icon-build.svg deleted file mode 100644 index 3b3bd492f2e..00000000000 --- a/apps/dashboard/public/assets/solutions-pages/icons/icon-build.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/solutions-pages/icons/icon-efficient.svg b/apps/dashboard/public/assets/solutions-pages/icons/icon-efficient.svg deleted file mode 100644 index 8598dd51be5..00000000000 --- a/apps/dashboard/public/assets/solutions-pages/icons/icon-efficient.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/solutions-pages/icons/icon-simple-click.svg b/apps/dashboard/public/assets/solutions-pages/icons/icon-simple-click.svg deleted file mode 100644 index 72e4a731055..00000000000 --- a/apps/dashboard/public/assets/solutions-pages/icons/icon-simple-click.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/solutions-pages/icons/icon-verified.svg b/apps/dashboard/public/assets/solutions-pages/icons/icon-verified.svg deleted file mode 100644 index fdc67dcb7f1..00000000000 --- a/apps/dashboard/public/assets/solutions-pages/icons/icon-verified.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/dashboard/public/assets/solutions-pages/loyalty/icon-1.png b/apps/dashboard/public/assets/solutions-pages/loyalty/icon-1.png deleted file mode 100644 index 78b148b1878..00000000000 Binary files a/apps/dashboard/public/assets/solutions-pages/loyalty/icon-1.png and /dev/null differ diff --git a/apps/dashboard/public/assets/solutions-pages/minting/hero.png b/apps/dashboard/public/assets/solutions-pages/minting/hero.png deleted file mode 100644 index cfa17ee4b2c..00000000000 Binary files a/apps/dashboard/public/assets/solutions-pages/minting/hero.png and /dev/null differ diff --git a/apps/dashboard/public/assets/support/account.svg b/apps/dashboard/public/assets/support/account.svg deleted file mode 100644 index 8d86babc3c4..00000000000 --- a/apps/dashboard/public/assets/support/account.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/dashboard/public/assets/support/contracts.png b/apps/dashboard/public/assets/support/contracts.png deleted file mode 100644 index be595d9b583..00000000000 Binary files a/apps/dashboard/public/assets/support/contracts.png and /dev/null differ diff --git a/apps/dashboard/public/assets/support/discord-illustration.png b/apps/dashboard/public/assets/support/discord-illustration.png deleted file mode 100644 index bf9f4090729..00000000000 Binary files a/apps/dashboard/public/assets/support/discord-illustration.png and /dev/null differ diff --git a/apps/dashboard/public/assets/support/engine.png b/apps/dashboard/public/assets/support/engine.png deleted file mode 100644 index bb83199f0a6..00000000000 Binary files a/apps/dashboard/public/assets/support/engine.png and /dev/null differ diff --git a/apps/dashboard/public/assets/support/misc.svg b/apps/dashboard/public/assets/support/misc.svg deleted file mode 100644 index eb8a769ff8c..00000000000 --- a/apps/dashboard/public/assets/support/misc.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/dashboard/public/assets/support/wallets.png b/apps/dashboard/public/assets/support/wallets.png deleted file mode 100644 index b9738457f4e..00000000000 Binary files a/apps/dashboard/public/assets/support/wallets.png and /dev/null differ diff --git a/apps/dashboard/public/assets/tokens/coin-dark.png b/apps/dashboard/public/assets/tokens/coin-dark.png new file mode 100644 index 00000000000..8a429cf2455 Binary files /dev/null and b/apps/dashboard/public/assets/tokens/coin-dark.png differ diff --git a/apps/dashboard/public/assets/tokens/coin-light.png b/apps/dashboard/public/assets/tokens/coin-light.png new file mode 100644 index 00000000000..14d03b45d83 Binary files /dev/null and b/apps/dashboard/public/assets/tokens/coin-light.png differ diff --git a/apps/dashboard/public/assets/tokens/nft-dark.png b/apps/dashboard/public/assets/tokens/nft-dark.png new file mode 100644 index 00000000000..db1bf2ef084 Binary files /dev/null and b/apps/dashboard/public/assets/tokens/nft-dark.png differ diff --git a/apps/dashboard/public/assets/tokens/nft-light.png b/apps/dashboard/public/assets/tokens/nft-light.png new file mode 100644 index 00000000000..a455957308c Binary files /dev/null and b/apps/dashboard/public/assets/tokens/nft-light.png differ diff --git a/apps/dashboard/public/assets/tw-icons/analytics.png b/apps/dashboard/public/assets/tw-icons/analytics.png deleted file mode 100644 index 2fa8d2ba62f..00000000000 Binary files a/apps/dashboard/public/assets/tw-icons/analytics.png and /dev/null differ diff --git a/apps/dashboard/public/assets/tw-icons/datastore.png b/apps/dashboard/public/assets/tw-icons/datastore.png deleted file mode 100644 index 7b2a4bcd11c..00000000000 Binary files a/apps/dashboard/public/assets/tw-icons/datastore.png and /dev/null differ diff --git a/apps/dashboard/public/assets/tw-icons/docs.svg b/apps/dashboard/public/assets/tw-icons/docs.svg deleted file mode 100644 index cd487e0228d..00000000000 --- a/apps/dashboard/public/assets/tw-icons/docs.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/dashboard/public/assets/tw-icons/guides.svg b/apps/dashboard/public/assets/tw-icons/guides.svg deleted file mode 100644 index ee525070b13..00000000000 --- a/apps/dashboard/public/assets/tw-icons/guides.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/dashboard/public/assets/tw-icons/mission.svg b/apps/dashboard/public/assets/tw-icons/mission.svg deleted file mode 100644 index c8697f4a74f..00000000000 --- a/apps/dashboard/public/assets/tw-icons/mission.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/assets/tw-icons/opensource.svg b/apps/dashboard/public/assets/tw-icons/opensource.svg deleted file mode 100644 index a9c46afca49..00000000000 --- a/apps/dashboard/public/assets/tw-icons/opensource.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/assets/tw-icons/templates.svg b/apps/dashboard/public/assets/tw-icons/templates.svg deleted file mode 100644 index 1a4e7cda68b..00000000000 --- a/apps/dashboard/public/assets/tw-icons/templates.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/logos/wallet.png b/apps/dashboard/public/logos/wallet.png deleted file mode 100644 index 8297fc14389..00000000000 Binary files a/apps/dashboard/public/logos/wallet.png and /dev/null differ diff --git a/apps/dashboard/redirects.js b/apps/dashboard/redirects.js index d8d6b6bf612..bf5fab03dba 100644 --- a/apps/dashboard/redirects.js +++ b/apps/dashboard/redirects.js @@ -1,64 +1,147 @@ const legacyDashboardToTeamRedirects = [ { - source: "/dashboard", destination: "/team", permanent: false, + source: "/dashboard", }, { - source: "/dashboard/contracts/:path*", destination: "/team/~/~/contracts", permanent: false, + source: "/dashboard/contracts/:path*", }, { - source: "/dashboard/connect/ecosystem/:path*", destination: "/team/~/~/ecosystem/:path*", permanent: false, + source: "/dashboard/connect/ecosystem/:path*", }, { - source: "/dashboard/engine/:path*", destination: "/team/~/~/engine/:path*", permanent: false, + source: "/dashboard/engine/:path*", }, { - source: "/dashboard/settings/api-keys", destination: "/team", permanent: false, + source: "/dashboard/settings/api-keys", }, { - source: "/dashboard/settings/devices", destination: "/account/devices", permanent: false, + source: "/dashboard/settings/devices", }, { - source: "/dashboard/settings/billing", destination: "/team/~/~/settings/billing", permanent: false, + source: "/dashboard/settings/billing", }, { - source: "/dashboard/settings/gas-credits", destination: "/team/~/~/settings/credits", permanent: false, + source: "/dashboard/settings/gas-credits", }, { - source: "/dashboard/settings/usage", destination: "/team/~/~/usage", permanent: false, + source: "/dashboard/settings/usage", }, { - source: "/dashboard/settings/storage", destination: "/team/~/~/usage/storage", permanent: false, + source: "/dashboard/settings/storage", }, { - source: "/dashboard/settings/notifications", destination: "/team/~/~/settings/notifications", permanent: false, + source: "/dashboard/settings/notifications", }, // rest of the /dashboard/* routes { - source: "/dashboard/:path*", destination: "/team", permanent: false, + source: "/dashboard/:path*", + }, +]; + +const projectRoute = "/team/:team_slug/:project_slug"; + +const projectPageRedirects = [ + { + destination: `${projectRoute}/payments/:path*`, + permanent: false, + source: `${projectRoute}/connect/pay/:path*`, + }, + { + destination: `${projectRoute}/payments/:path*`, + permanent: false, + source: `${projectRoute}/connect/universal-bridge/:path*`, + }, + { + destination: `${projectRoute}/payments/:path*`, + permanent: false, + source: `${projectRoute}/universal-bridge/:path*`, + }, + { + destination: `${projectRoute}/account-abstraction/:path*`, + permanent: false, + source: `${projectRoute}/connect/account-abstraction/:path*`, + }, + { + destination: `${projectRoute}/wallets/:path*`, + permanent: false, + source: `${projectRoute}/connect/in-app-wallets/:path*`, + }, + { + destination: `${projectRoute}/vault/:path*`, + permanent: false, + source: `${projectRoute}/engine/cloud/vault/:path*`, + }, + { + destination: `${projectRoute}/transactions/:path*`, + permanent: false, + source: `${projectRoute}/engine/cloud/:path*`, + }, + { + destination: `${projectRoute}/tokens/:path*`, + permanent: false, + source: `${projectRoute}/assets/:path*`, + }, + { + destination: projectRoute, + permanent: false, + source: `${projectRoute}/nebula/:path*`, + }, + { + destination: `${projectRoute}`, + permanent: false, + source: `${projectRoute}/connect/analytics`, + }, + { + destination: `${projectRoute}/gateway/indexer/:path*`, + permanent: false, + source: `${projectRoute}/insight/:path*`, + }, + { + destination: `${projectRoute}/gateway/rpc/:path*`, + permanent: false, + source: `${projectRoute}/rpc/:path*`, + }, +]; + +const teamPageRedirects = [ + { + destination: "/team/:team_slug/~/billing/:path*", + permanent: false, + source: "/team/:team_slug/~/settings/billing/:path*", + }, + { + destination: "/team/:team_slug/~/billing/invoices/:path*", + permanent: false, + source: "/team/:team_slug/~/settings/invoices/:path*", + }, + { + destination: "/team/:team_slug/~/billing", + permanent: false, + source: "/team/:team_slug/~/settings/credits", }, ]; @@ -66,290 +149,351 @@ const legacyDashboardToTeamRedirects = [ async function redirects() { return [ { - source: "/portal/:match*", destination: "https://portal.thirdweb.com/:match*", permanent: true, + source: "/portal/:match*", }, { - source: "/solutions/appchain-api", destination: "/solutions/chains", permanent: true, + source: "/solutions/appchain-api", }, { - source: "/contracts/release", destination: "/contracts/publish", permanent: false, + source: "/contracts/release", }, { - source: "/contracts/release/:path*", destination: "/contracts/publish/:path*", permanent: false, + source: "/contracts/release/:path*", }, { - source: "/release", destination: "/publish", permanent: false, + source: "/release", }, { - source: "/release/:path*", destination: "/publish/:path*", permanent: false, + source: "/release/:path*", }, { - source: "/authentication", destination: "/auth", permanent: false, + source: "/authentication", }, { - source: "/checkout", - destination: "/connect", - permanent: false, - }, - { - source: "/extensions", destination: "/build", permanent: false, + source: "/extensions", }, { - source: "/contractkit", destination: "/build", permanent: true, + source: "/contractkit", }, // old (deprecated) routes { - source: - "/:network/(edition|nft-collection|token|nft-drop|signature-drop|edition-drop|token-drop|vote)/:address", destination: "/:network/:address", permanent: false, + source: + "/:network/(edition|nft-collection|token|nft-drop|signature-drop|edition-drop|token-drop|vote)/:address", }, // prebuilt contract deploys { - source: "/contracts/new/:slug*", destination: "/explore", permanent: false, + source: "/contracts/new/:slug*", }, // deployer to non-deployer url // handled directly in SSR as well { - source: "/deployer.thirdweb.eth", destination: "/thirdweb.eth", permanent: true, + source: "/deployer.thirdweb.eth", }, { - source: "/deployer.thirdweb.eth/:path*", destination: "/thirdweb.eth/:path*", permanent: true, + source: "/deployer.thirdweb.eth/:path*", }, { - source: "/chains", destination: "/chainlist", permanent: true, + source: "/chains", }, // polygon zkevm beta to non-beta { - source: "/polygon-zkevm-beta", destination: "/polygon-zkevm", permanent: false, + source: "/polygon-zkevm-beta", }, // backwards compat: page moved to pages/settings/devices { - source: "/template/nft-drop", destination: "/template/erc721", permanent: false, + source: "/template/nft-drop", }, { - source: "/create-api-key", destination: "/team", permanent: false, + source: "/create-api-key", }, { - source: "/dashboard/settings", destination: "/team", permanent: false, + source: "/dashboard/settings", }, { - source: "/dashboard/connect/playground", destination: "https://playground.thirdweb.com/connect/sign-in/button", permanent: false, + source: "/dashboard/connect/playground", }, { - source: "/dashboard/infrastructure/storage", destination: "/dashboard/settings/storage", permanent: false, + source: "/dashboard/infrastructure/storage", }, { - source: "/dashboard/infrastructure/rpc-edge", destination: "/chainlist", permanent: false, + source: "/dashboard/infrastructure/rpc-edge", }, { - source: "/solutions/commerce", destination: "/solutions/loyalty", permanent: false, + source: "/solutions/commerce", }, { - source: "/hackathon/base-consumer-crypto", destination: "/hackathon/consumer-crypto", permanent: false, + source: "/hackathon/base-consumer-crypto", }, { - source: "/bear-market-airdrop", destination: "/", permanent: false, + source: "/bear-market-airdrop", }, { - source: "/drops/optimism", destination: "/optimism", permanent: false, + source: "/drops/optimism", }, // Redirecting as ambassadors lives in community now { - source: "/ambassadors", destination: "/community/ambassadors", permanent: false, + source: "/ambassadors", }, { - source: "/embedded-wallets", destination: "/in-app-wallets", permanent: false, + source: "/embedded-wallets", }, // temporarily redirect cli login to support page { - source: "/cli/login", destination: - "https://support.thirdweb.com/troubleshooting-errors/7Y1BqKNvtLdBv5fZkRZZB3/issue-linking-device-on-the-authorization-page-via-thirdweb-cli/cn9LRA3ax7XCP6uxwRYdvx", + "https://portal.thirdweb.com/knowledge-base/onchain-common-errors/thirdweb-cli/device-link-error", permanent: false, + source: "/cli/login", }, // temporary redirect gas -> explore page { - source: "/gas", destination: "/explore", permanent: false, + source: "/gas", }, { - source: "/deploy", destination: "/contracts/deployment-tool", permanent: false, + source: "/deploy", }, { - source: "/publish", destination: "/contracts/deployment-tool", permanent: false, + source: "/publish", }, { - source: "/smart-contracts", destination: "/contracts/explore", permanent: false, + source: "/smart-contracts", }, { - source: "/build", - destination: "/contracts/modular-contracts", - permanent: false, - }, - { - source: "/ui-components", destination: "/sdk", permanent: false, + source: "/ui-components", }, { - source: "/interact", destination: "/sdk", permanent: false, + source: "/interact", }, { - source: "/sponsored-transactions", destination: "/account-abstraction", permanent: false, + source: "/sponsored-transactions", }, // redirect /solutions/chains to /solutions/ecosystem { - source: "/solutions/chains", destination: "/solutions/ecosystem", permanent: false, - }, - // redirect /storage to portal - { - source: "/storage", - destination: - "https://portal.thirdweb.com/infrastructure/storage/overview", - permanent: false, - }, - // redirect /rpc to portal - { - source: "/rpc-edge", - destination: - "https://portal.thirdweb.com/infrastructure/rpc-edge/overview", - permanent: false, + source: "/solutions/chains", }, // redirect /sdk to portal { - source: "/sdk", destination: "https://portal.thirdweb.com/connect/blockchain-api", permanent: false, + source: "/sdk", }, // redirect `/events` to homepage { - source: "/events", destination: "/", permanent: false, + source: "/events", }, // redirect /community to /community/ambassadors { - source: "/community", destination: "/community/ambassadors", permanent: false, + source: "/community", }, // redirect `/tos` to `/terms` { - source: "/tos", destination: "/terms", permanent: false, + source: "/tos", }, // redirect `/privacy` to `/privacy-policy` { - source: "/privacy", destination: "/privacy-policy", permanent: false, + source: "/privacy", }, // redirect `/mission` to `/home` { - source: "/mission", destination: "/home", permanent: false, + source: "/mission", }, // redirect "/open-source" to "/bounties" { - source: "/open-source", destination: "/bounties", permanent: false, + source: "/open-source", }, // redirect /template/ to /templates/ { - source: "/template/:slug", destination: "/templates/:slug", permanent: false, + source: "/template/:slug", }, - // redirect /account-abstraction to /connect/account-abstraction + // redirect /connect/pay to /payments { - source: "/account-abstraction", - destination: "/connect/account-abstraction", - permanent: false, + destination: "/payments", + permanent: true, + source: "/connect/pay", }, - // redirect /connect/pay to /connect/universal-bridge { - source: "/connect/pay", - destination: "/connect/universal-bridge", - permanent: false, + destination: "/payments", + permanent: true, + source: "/universal-bridge", + }, + { + destination: "/payments/:slug", + permanent: true, + source: "/universal-bridge/:slug", }, // PREVIOUS CAMPAIGNS { - source: "/unlimited-wallets", destination: "/", permanent: false, + source: "/unlimited-wallets", + }, + // all /learn/tutorials (and sub-routes) -> /learn/guides + { + destination: "/learn/guides/:path*", + permanent: false, + source: "/learn/tutorials/:path*", + }, + { + destination: "/learn/guides", + permanent: false, + source: "/learn/tutorials", + }, + // redirect to /grant/superchain to /superchain + { + destination: "/superchain", + permanent: false, + source: "/grant/superchain", + }, + // connect -> build redirects + { + destination: "/wallets", + permanent: false, + source: "/connect", + }, + { + destination: "/account-abstraction", + permanent: false, + source: "/connect/account-abstraction", + }, + { + destination: "/payments", + permanent: false, + source: "/connect/universal-bridge", + }, + { + destination: "/auth", + permanent: false, + source: "/connect/auth", + }, + { + destination: "/in-app-wallets", + permanent: false, + source: "/connect/in-app-wallets", + }, + { + destination: "/transactions", + permanent: false, + source: "/engine", + }, + { + destination: "/rpc", + permanent: false, + source: "/rpc-edge", + }, + { + destination: "/payments", + permanent: false, + source: "/universal-bridge", + }, + // redirect /nebula to /ai + { + destination: "/ai", + permanent: false, + source: "/nebula", }, ...legacyDashboardToTeamRedirects, + ...projectPageRedirects, + ...teamPageRedirects, + { + source: "/support/:path*", + destination: "/team/~/~/support", + permanent: false, + }, + { + source: "/routes", + destination: "/tokens", + permanent: false, + }, + { + source: "/payments/x402", + destination: "/x402", + permanent: false, + }, ]; } diff --git a/apps/dashboard/scripts/deleteUnusedAssets.ts b/apps/dashboard/scripts/deleteUnusedAssets.ts new file mode 100644 index 00000000000..3b891c622ba --- /dev/null +++ b/apps/dashboard/scripts/deleteUnusedAssets.ts @@ -0,0 +1,28 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +// after running logUnusedAssets.ts, +// 1. validate the output +// 2. If you think that a file should not be deleted, remove it from the output +// 3. paste the output files in `filesToDelete` and run this script to delete them +const filesToDelete: string[] = []; + +async function deleteFiles() { + for (const filePath of filesToDelete) { + try { + const fullPath = path.join(process.cwd(), filePath); + await fs.unlink(fullPath); + console.log(`Deleted: ${filePath}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + console.warn(`File not found: ${filePath}`); + } else { + console.error(`Error deleting ${filePath}:`, error); + } + } + } + + console.log("--- DONE ---"); +} + +deleteFiles(); diff --git a/apps/dashboard/scripts/logUnusedAssets.ts b/apps/dashboard/scripts/logUnusedAssets.ts index 2892a19eef3..9885104c7c4 100644 --- a/apps/dashboard/scripts/logUnusedAssets.ts +++ b/apps/dashboard/scripts/logUnusedAssets.ts @@ -26,9 +26,6 @@ const filesToIgnore = new Set([ // macOS stuff "public/assets/product-pages/.DS_Store", "public/assets/.DS_Store", - // pdfs - "public/Thirdweb_Terms_of_Service.pdf", - "public/thirdweb_Privacy_Policy_May_2022.pdf", ]); function getAllFilesInFolder(folderPath: string) { @@ -91,7 +88,7 @@ function main(params: { assetsFolder: string; srcFolder: string }) { // remove the files for which its name can be found in the content of the file folderVisitor(params.srcFolder, (content) => { for (const assetFileName of assetFileNames) { - if (content.includes(assetFileName.name)) { + if (content.includes(`/${assetFileName.name}`)) { unusedFileNames.delete(assetFileName); } } diff --git a/apps/dashboard/sentry.client.config.ts b/apps/dashboard/sentry.client.config.ts deleted file mode 100644 index aa8b26ff8fa..00000000000 --- a/apps/dashboard/sentry.client.config.ts +++ /dev/null @@ -1,97 +0,0 @@ -// This file configures the initialization of Sentry on the client. -// The config you add here will be used whenever a users loads a page in their browser. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: "https://8813f5d93c8c4aa89eda86816f0b1bbf@o1378374.ingest.sentry.io/6690186", - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 0.1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - replaysOnErrorSampleRate: 1.0, - - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, - - // You can remove this option if you're not planning to use the Sentry Session Replay feature: - integrations: [ - Sentry.replayIntegration({ - // Additional Replay configuration goes in here, for example: - maskAllText: true, - blockAllMedia: true, - }), - ], - ignoreErrors: [ - // Random plugins/extensions - "top.GLOBALS", - // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html - "originalCreateNotification", - "canvas.contentDocument", - "MyApp_RemoveAllHighlights", - "http://tt.epicplay.com", - "Can't find variable: ZiteReader", - "jigsaw is not defined", - "ComboSearch is not defined", - "http://loading.retry.widdit.com/", - "atomicFindClose", - // Facebook borked - "fb_xd_fragment", - // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to reduce this. (thanks @acdha) - // See http://stackoverflow.com/questions/4113268/how-to-stop-javascript-injection-from-vodafone-proxy - "bmi_SafeAddOnload", - "EBCallBackMessageReceived", - // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx - "conduitPage", - // Avast extension error - "_avast_submit", - // Common non-actionable errors - "rejected transaction", - "User closed modal", - "Loading chunk", - "Failed to execute '", - "NetworkError when attempting to fetch resource.", - "googlefc is not defined", - "__cmp is not defined", - "Cannot read properties of undefined (reading 'cmp')", - "Cannot read properties of undefined (reading 'outputCurrentConfiguration')", - "apstagLOADED is not defined", - "moat_px is not defined", - "window.ReactNativeWebView.postMessage is not a function", - "_reportEvent is not defined", - "requestAnimationFrame is not defined", - "window.requestAnimationFrame is not a function", - "tronLink.setAddress is not a function", - // benign errors - "ResizeObserver loop limit exceeded", - // cannot do anything with these errors - "Non-Error promise rejection captured", - ], - denyUrls: [ - // Google Adsense - /pagead\/js/i, - // Facebook flakiness - /graph\.facebook\.com/i, - // Facebook blocked - /connect\.facebook\.net\/en_US\/all\.js/i, - // Woopra flakiness - /eatdifferent\.com\.woopra-ns\.com/i, - /static\.woopra\.com\/js\/woopra\.js/i, - // Chrome extensions - /extensions\//i, - /^chrome:\/\//i, - // Other plugins - // Cacaoweb - /127\.0\.0\.1:4001\/isrunning/i, - /webappstoolbarba\.texthelp\.com\//i, - /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, - // injected (extensions) - /inject/i, - ], - allowUrls: [/thirdweb-dev\.com/i, /thirdweb\.com/, /thirdweb-preview\.com/], -}); diff --git a/apps/dashboard/src/@/actions/account/confirmEmail.ts b/apps/dashboard/src/@/actions/account/confirmEmail.ts new file mode 100644 index 00000000000..f978cffda05 --- /dev/null +++ b/apps/dashboard/src/@/actions/account/confirmEmail.ts @@ -0,0 +1,42 @@ +"use server"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export async function confirmEmailWithOTP(otp: string) { + const token = await getAuthToken(); + + if (!token) { + return { + errorMessage: "You are not authorized to perform this action", + }; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account/confirmEmail`, + { + body: JSON.stringify({ + confirmationToken: otp, + }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "PUT", + }, + ); + + if (!res.ok) { + const json = await res.json(); + + if (json.error) { + return { + errorMessage: json.error.message, + }; + } + + return { + errorMessage: "Failed to confirm email", + }; + } +} diff --git a/apps/dashboard/src/@/actions/account/getAccount.ts b/apps/dashboard/src/@/actions/account/getAccount.ts new file mode 100644 index 00000000000..1fbbdb0d407 --- /dev/null +++ b/apps/dashboard/src/@/actions/account/getAccount.ts @@ -0,0 +1,7 @@ +"use server"; + +import { getRawAccount } from "@/api/account/get-account"; + +export async function getRawAccountAction() { + return getRawAccount(); +} diff --git a/apps/dashboard/src/@/actions/updateAccount.ts b/apps/dashboard/src/@/actions/account/updateAccount.ts similarity index 75% rename from apps/dashboard/src/@/actions/updateAccount.ts rename to apps/dashboard/src/@/actions/account/updateAccount.ts index 0c4d2378d47..39131f024a0 100644 --- a/apps/dashboard/src/@/actions/updateAccount.ts +++ b/apps/dashboard/src/@/actions/account/updateAccount.ts @@ -1,6 +1,6 @@ "use server"; -import { getAuthToken } from "../../app/api/lib/getAuthToken"; -import { API_SERVER_URL } from "../constants/env"; +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; export async function updateAccount(values: { name?: string; @@ -13,13 +13,13 @@ export async function updateAccount(values: { throw new Error("No Auth token"); } - const res = await fetch(`${API_SERVER_URL}/v1/account`, { - method: "PUT", + const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account`, { + body: JSON.stringify(values), headers: { - "Content-Type": "application/json", Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - body: JSON.stringify(values), + method: "PUT", }); if (!res.ok) { diff --git a/apps/dashboard/src/@/actions/auth-actions.ts b/apps/dashboard/src/@/actions/auth-actions.ts new file mode 100644 index 00000000000..4cfaf54ed3c --- /dev/null +++ b/apps/dashboard/src/@/actions/auth-actions.ts @@ -0,0 +1,225 @@ +"use server"; +import "server-only"; + +import { cookies } from "next/headers"; +import { getAddress } from "thirdweb"; +import type { + GenerateLoginPayloadParams, + LoginPayload, + VerifyLoginPayloadParams, +} from "thirdweb/auth"; +import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import { API_SERVER_SECRET } from "@/constants/server-envs"; +import { isVercel } from "@/utils/vercel"; +import { verifyTurnstileToken } from "../../app/login/verifyTurnstileToken"; + +export async function getLoginPayload( + params: GenerateLoginPayloadParams, +): Promise { + if (!API_SERVER_SECRET) { + throw new Error("API_SERVER_SECRET is not set"); + } + const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v2/siwe/payload`, { + body: JSON.stringify({ + address: params.address, + chainId: params.chainId?.toString(), + }), + headers: { + "Content-Type": "application/json", + "x-service-api-key": API_SERVER_SECRET, + }, + method: "POST", + }); + + if (!res.ok) { + console.error("Failed to fetch login payload", res.status, res.statusText); + throw new Error("Failed to fetch login payload"); + } + return (await res.json()).data.payload; +} + +export async function doLogin( + payload: VerifyLoginPayloadParams, + turnstileToken: string | undefined, +) { + if (!API_SERVER_SECRET) { + throw new Error("API_SERVER_SECRET is not set"); + } + + // only validate the turnstile token if we are in a vercel environment + if (isVercel()) { + if (!turnstileToken) { + return { + error: "Please complete the captcha.", + }; + } + + const result = await verifyTurnstileToken(turnstileToken); + if (!result.success) { + return { + context: result.context, + error: "Invalid captcha. Please try again.", + }; + } + } + + const cookieStore = await cookies(); + const utmCookies = cookieStore + .getAll() + .filter((cookie) => { + return cookie.name.startsWith("utm_"); + }) + .reduce( + (acc, cookie) => { + acc[cookie.name] = cookie.value; + return acc; + }, + {} as Record, + ); + + // forward the request to the API server + const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v2/siwe/login`, { + // set the createAccount flag to true to create a new account if it does not exist + body: JSON.stringify({ ...payload, createAccount: true, utm: utmCookies }), + headers: { + "Content-Type": "application/json", + "x-service-api-key": API_SERVER_SECRET, + }, + method: "POST", + }); + + // if the request failed, log the error and throw an error + if (!res.ok) { + try { + // clear the cookies to prevent any weird issues + cookieStore.delete( + COOKIE_PREFIX_TOKEN + getAddress(payload.payload.address), + ); + cookieStore.delete(COOKIE_ACTIVE_ACCOUNT); + } catch { + // ignore any errors on this + } + try { + const response = await res.text(); + // try to log the rich error message + console.error( + "Failed to login - api call failed:", + res.status, + res.statusText, + response, + ); + return { + error: "Failed to login. Please try again later.", + }; + } catch { + // just log the basics + console.error( + "Failed to login - api call failed", + res.status, + res.statusText, + ); + } + return { + error: "Failed to login. Please try again later.", + }; + } + + const json = await res.json(); + + const jwt = json.data.jwt; + + if (!jwt) { + console.error("Failed to login - invalid json", json); + return { + error: "Failed to login. Please try again later.", + }; + } + + // set the token cookie + cookieStore.set( + COOKIE_PREFIX_TOKEN + getAddress(payload.payload.address), + jwt, + { + httpOnly: true, + // 3 days + maxAge: 3 * 24 * 60 * 60, + sameSite: "strict", + secure: true, + }, + ); + + // set the active account cookie + cookieStore.set(COOKIE_ACTIVE_ACCOUNT, getAddress(payload.payload.address), { + httpOnly: true, + // 3 days + maxAge: 3 * 24 * 60 * 60, + sameSite: "strict", + secure: true, + }); + + return { + success: true, + }; +} + +export async function doLogout() { + const cookieStore = await cookies(); + // delete all cookies that start with the token prefix + const allCookies = cookieStore.getAll(); + for (const cookie of allCookies) { + if (cookie.name.startsWith(COOKIE_PREFIX_TOKEN)) { + cookieStore.delete(cookie.name); + } + } + // also delete the active account cookie + cookieStore.delete(COOKIE_ACTIVE_ACCOUNT); +} + +export async function isLoggedIn(address: string) { + const cookieName = COOKIE_PREFIX_TOKEN + getAddress(address); + const cookieStore = await cookies(); + // check if we have an access token + const token = cookieStore.get(cookieName)?.value; + if (!token) { + return false; + } + + const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account/me`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "GET", + }); + + if (!res.ok) { + console.error( + "Failed to check if logged in - api call failed", + res.status, + res.statusText, + ); + // not logged in + // clear the cookie + cookieStore.delete(cookieName); + return false; + } + const json = await res.json(); + + if (!json) { + // not logged in + // clear the cookie + cookieStore.delete(cookieName); + return false; + } + + // set the active account cookie again + cookieStore.set(COOKIE_ACTIVE_ACCOUNT, getAddress(address), { + httpOnly: false, + // 3 days + maxAge: 3 * 24 * 60 * 60, + sameSite: "strict", + secure: true, + }); + return true; +} diff --git a/apps/dashboard/src/@/actions/billing.ts b/apps/dashboard/src/@/actions/billing.ts index f6eded82195..1eb0a9b78ba 100644 --- a/apps/dashboard/src/@/actions/billing.ts +++ b/apps/dashboard/src/@/actions/billing.ts @@ -1,28 +1,15 @@ "use server"; - import "server-only"; -import { API_SERVER_URL } from "@/constants/env"; -import { redirect } from "next/navigation"; -import { getAuthToken } from "../../app/api/lib/getAuthToken"; -import type { ProductSKU } from "../lib/billing"; -export type RedirectCheckoutOptions = { - teamSlug: string; - sku: ProductSKU; - redirectUrl: string; - metadata?: Record; -}; +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import type { ChainInfraSKU } from "@/types/billing"; +import { getAbsoluteUrl } from "@/utils/vercel"; -export async function redirectToCheckout( - options: RedirectCheckoutOptions, -): Promise<{ status: number }> { - if (!options.teamSlug) { - return { - status: 400, - }; - } +export async function reSubscribePlan(options: { + teamId: string; +}): Promise<{ status: number }> { const token = await getAuthToken(); - if (!token) { return { status: 401, @@ -30,88 +17,119 @@ export async function redirectToCheckout( } const res = await fetch( - `${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-link`, + new URL( + `/v1/teams/${options.teamId}/checkout/resubscribe-plan`, + NEXT_PUBLIC_THIRDWEB_API_HOST, + ), { - method: "POST", - body: JSON.stringify({ - sku: options.sku, - redirectTo: options.redirectUrl, - metadata: options.metadata || {}, - }), + body: JSON.stringify({}), headers: { - "Content-Type": "application/json", Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, + method: "PUT", }, ); + if (!res.ok) { return { status: res.status, }; } - const json = await res.json(); - if (!json.result) { - return { - status: 500, - }; - } - // redirect to the stripe checkout session - redirect(json.result); + return { + status: 200, + }; } -export type RedirectBillingCheckoutAction = typeof redirectToCheckout; - -export type BillingPortalOptions = { - teamSlug: string | undefined; - redirectUrl: string; -}; - -export async function redirectToBillingPortal( - options: BillingPortalOptions, -): Promise<{ status: number }> { - if (!options.teamSlug) { - return { - status: 400, - }; - } +export async function getChainInfraCheckoutURL(options: { + teamSlug: string; + skus: ChainInfraSKU[]; + chainId: number; + annual: boolean; +}) { const token = await getAuthToken(); + if (!token) { return { - status: 401, - }; + error: "You are not logged in", + status: "error", + } as const; } const res = await fetch( - `${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-session-link`, + new URL( + `/v1/teams/${options.teamSlug}/checkout/create-link`, + NEXT_PUBLIC_THIRDWEB_API_HOST, + ), { - method: "POST", body: JSON.stringify({ - redirectTo: options.redirectUrl, + annual: options.annual, + baseUrl: getAbsoluteUrl(), + chainId: options.chainId, + skus: options.skus, }), headers: { - "Content-Type": "application/json", Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, + method: "POST", }, ); - if (!res.ok) { - return { - status: res.status, - }; + const text = await res.text(); + console.error("Failed to create checkout link", text, res.status); + switch (res.status) { + case 402: { + return { + error: + "You have outstanding invoices, please pay these first before re-subscribing.", + status: "error", + } as const; + } + case 429: { + return { + error: "Too many requests, please try again later.", + status: "error", + } as const; + } + case 403: { + return { + error: "You are not authorized to deploy infrastructure.", + status: "error", + } as const; + } + default: { + return { + error: "An unknown error occurred, please try again later.", + status: "error", + } as const; + } + } } const json = await res.json(); + if ( + "error" in json && + "message" in json.error && + typeof json.error.message === "string" + ) { + return { + error: json.error.message, + status: "error", + } as const; + } + if (!json.result) { return { - status: 500, - }; + error: "An unknown error occurred, please try again later.", + status: "error", + } as const; } - // redirect to the stripe billing portal - redirect(json.result); + return { + data: json.result as string, + status: "success", + } as const; } - -export type BillingBillingPortalAction = typeof redirectToBillingPortal; diff --git a/apps/dashboard/src/@/actions/confirmEmail.ts b/apps/dashboard/src/@/actions/confirmEmail.ts deleted file mode 100644 index 6f1216a9fea..00000000000 --- a/apps/dashboard/src/@/actions/confirmEmail.ts +++ /dev/null @@ -1,39 +0,0 @@ -"use server"; - -import { getAuthToken } from "../../app/api/lib/getAuthToken"; -import { API_SERVER_URL } from "../constants/env"; - -export async function confirmEmailWithOTP(otp: string) { - const token = await getAuthToken(); - - if (!token) { - return { - errorMessage: "You are not authorized to perform this action", - }; - } - - const res = await fetch(`${API_SERVER_URL}/v1/account/confirmEmail`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - confirmationToken: otp, - }), - }); - - if (!res.ok) { - const json = await res.json(); - - if (json.error) { - return { - errorMessage: json.error.message, - }; - } - - return { - errorMessage: "Failed to confirm email", - }; - } -} diff --git a/apps/dashboard/src/@/actions/emailSignup.ts b/apps/dashboard/src/@/actions/emailSignup.ts deleted file mode 100644 index fdd03cf3a7b..00000000000 --- a/apps/dashboard/src/@/actions/emailSignup.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; - -type EmailSignupParams = { - email: string; - send_welcome_email?: boolean; -}; - -export async function emailSignup(payLoad: EmailSignupParams) { - const response = await fetch( - "https://api.beehiiv.com/v2/publications/pub_9f54090a-6d14-406b-adfd-dbb30574f664/subscriptions", - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, - }, - method: "POST", - body: JSON.stringify({ - email: payLoad.email, - send_welcome_email: payLoad.send_welcome_email || false, - utm_source: "thirdweb.com", - }), - }, - ); - - return { - status: response.status, - }; -} diff --git a/apps/dashboard/src/@/actions/getAccount.ts b/apps/dashboard/src/@/actions/getAccount.ts deleted file mode 100644 index 97f7fca5df5..00000000000 --- a/apps/dashboard/src/@/actions/getAccount.ts +++ /dev/null @@ -1,7 +0,0 @@ -"use server"; - -import { getRawAccount } from "../../app/account/settings/getAccount"; - -export async function getRawAccountAction() { - return getRawAccount(); -} diff --git a/apps/dashboard/src/@/actions/getBalancesFromMoralis.ts b/apps/dashboard/src/@/actions/getBalancesFromMoralis.ts deleted file mode 100644 index 9c82d9e850a..00000000000 --- a/apps/dashboard/src/@/actions/getBalancesFromMoralis.ts +++ /dev/null @@ -1,89 +0,0 @@ -"use server"; - -import { getThirdwebClient } from "@/constants/thirdweb.server"; -import { defineDashboardChain } from "lib/defineDashboardChain"; -import { ZERO_ADDRESS, isAddress, toTokens } from "thirdweb"; -import { getWalletBalance } from "thirdweb/wallets"; - -type BalanceQueryResponse = Array<{ - balance: string; - decimals: number; - name?: string; - symbol: string; - token_address: string; - display_balance: string; -}>; - -export async function getTokenBalancesFromMoralis(params: { - contractAddress: string; - chainId: number; -}): Promise< - | { data: BalanceQueryResponse; error: undefined } - | { - data: undefined; - error: string; - } -> { - const { contractAddress, chainId } = params; - - if (!isAddress(contractAddress)) { - return { - data: undefined, - error: "invalid address", - }; - } - - const getNativeBalance = async (): Promise => { - // eslint-disable-next-line no-restricted-syntax - const chain = defineDashboardChain(chainId, undefined); - const balance = await getWalletBalance({ - address: contractAddress, - chain, - client: getThirdwebClient(), - }); - return [ - { - token_address: ZERO_ADDRESS, - symbol: balance.symbol, - name: "Native Token", - decimals: balance.decimals, - balance: balance.value.toString(), - display_balance: toTokens(balance.value, balance.decimals), - }, - ]; - }; - - const getTokenBalances = async (): Promise => { - const _chain = encodeURIComponent(`0x${chainId?.toString(16)}`); - const _address = encodeURIComponent(contractAddress); - const tokenBalanceEndpoint = `https://deep-index.moralis.io/api/v2/${_address}/erc20?chain=${_chain}`; - - const resp = await fetch(tokenBalanceEndpoint, { - method: "GET", - headers: { - "x-api-key": process.env.MORALIS_API_KEY || "", - }, - }); - - if (!resp.ok) { - resp.body?.cancel(); - return []; - } - const json = await resp.json(); - // biome-ignore lint/suspicious/noExplicitAny: FIXME - return json.map((balance: any) => ({ - ...balance, - display_balance: toTokens(BigInt(balance.balance), balance.decimals), - })); - }; - - const [nativeBalance, tokenBalances] = await Promise.all([ - getNativeBalance(), - getTokenBalances(), - ]); - - return { - error: undefined, - data: [...nativeBalance, ...tokenBalances], - }; -} diff --git a/apps/dashboard/src/@/actions/getWalletNFTs.ts b/apps/dashboard/src/@/actions/getWalletNFTs.ts deleted file mode 100644 index 519237727a8..00000000000 --- a/apps/dashboard/src/@/actions/getWalletNFTs.ts +++ /dev/null @@ -1,117 +0,0 @@ -"use server"; - -import { - generateAlchemyUrl, - isAlchemySupported, - transformAlchemyResponseToNFT, -} from "lib/wallet/nfts/alchemy"; -import { - generateMoralisUrl, - isMoralisSupported, - transformMoralisResponseToNFT, -} from "lib/wallet/nfts/moralis"; -import { - generateSimpleHashUrl, - isSimpleHashSupported, - transformSimpleHashResponseToNFT, -} from "lib/wallet/nfts/simpleHash"; -import type { WalletNFT } from "lib/wallet/nfts/types"; - -type WalletNFTApiReturn = - | { result: WalletNFT[]; error?: undefined } - | { result?: undefined; error: string }; - -export async function getWalletNFTs(params: { - chainId: number; - owner: string; -}): Promise { - const { chainId, owner } = params; - const supportedChainSlug = await isSimpleHashSupported(chainId); - - if (supportedChainSlug && process.env.SIMPLEHASH_API_KEY) { - const url = generateSimpleHashUrl({ chainSlug: supportedChainSlug, owner }); - - const response = await fetch(url, { - method: "GET", - headers: { - "X-API-KEY": process.env.SIMPLEHASH_API_KEY, - }, - next: { - revalidate: 10, // cache for 10 seconds - }, - }); - - if (response.status >= 400) { - return { - error: response.statusText, - }; - } - - try { - const parsedResponse = await response.json(); - const result = await transformSimpleHashResponseToNFT( - parsedResponse, - owner, - ); - - return { result }; - } catch { - return { error: "error parsing response" }; - } - } - - if (isAlchemySupported(chainId)) { - const url = generateAlchemyUrl({ chainId, owner }); - - const response = await fetch(url, { - next: { - revalidate: 10, // cache for 10 seconds - }, - }); - if (response.status >= 400) { - return { error: response.statusText }; - } - try { - const parsedResponse = await response.json(); - const result = await transformAlchemyResponseToNFT(parsedResponse, owner); - - return { result, error: undefined }; - } catch (err) { - console.error("Error fetching NFTs", err); - return { error: "error parsing response" }; - } - } - - if (isMoralisSupported(chainId) && process.env.MORALIS_API_KEY) { - const url = generateMoralisUrl({ chainId, owner }); - - const response = await fetch(url, { - method: "GET", - headers: { - "X-API-Key": process.env.MORALIS_API_KEY, - }, - next: { - revalidate: 10, // cache for 10 seconds - }, - }); - - if (response.status >= 400) { - return { error: response.statusText }; - } - - try { - const parsedResponse = await response.json(); - const result = await transformMoralisResponseToNFT( - await parsedResponse, - owner, - ); - - return { result }; - } catch (err) { - console.error("Error fetching NFTs", err); - return { error: "error parsing response" }; - } - } - - return { error: "unsupported chain" }; -} diff --git a/apps/dashboard/src/@/actions/joinWaitlist.ts b/apps/dashboard/src/@/actions/joinWaitlist.ts deleted file mode 100644 index cdc7ce0d5e5..00000000000 --- a/apps/dashboard/src/@/actions/joinWaitlist.ts +++ /dev/null @@ -1,40 +0,0 @@ -"use server"; - -import { getAuthToken } from "../../app/api/lib/getAuthToken"; -import { API_SERVER_URL } from "../constants/env"; - -export async function joinTeamWaitlist(options: { - teamSlug: string; - // currently only 'nebula' is supported - scope: "nebula"; -}) { - const { teamSlug, scope } = options; - const token = await getAuthToken(); - - if (!token) { - return { - errorMessage: "You are not authorized to perform this action", - }; - } - - const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - scope, - }), - }); - - if (!res.ok) { - return { - errorMessage: "Failed to join waitlist", - }; - } - - return { - success: true, - }; -} diff --git a/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts b/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts new file mode 100644 index 00000000000..ebd2e41e857 --- /dev/null +++ b/apps/dashboard/src/@/actions/project-wallet/list-server-wallets.ts @@ -0,0 +1,70 @@ +"use server"; + +import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk"; +import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs"; +import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; + +interface VaultWalletListItem { + id: string; + address: string; + metadata?: { + label?: string; + projectId?: string; + teamId?: string; + type?: string; + } | null; +} + +export async function listProjectServerWallets(params: { + managementAccessToken: string; + projectId: string; + pageSize?: number; +}): Promise { + const { managementAccessToken, projectId, pageSize = 100 } = params; + + if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) { + return []; + } + + try { + const vaultClient = await createVaultClient({ + baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL, + }); + + const response = await listEoas({ + client: vaultClient, + request: { + auth: { + accessToken: managementAccessToken, + }, + options: { + page: 0, + // @ts-expect-error - Vault SDK expects snake_case pagination fields + page_size: pageSize, + }, + }, + }); + + if (!response.success || !response.data?.items) { + return []; + } + + const items = response.data.items as VaultWalletListItem[]; + + return items + .filter((item) => { + return ( + item.metadata?.projectId === projectId && + (!item.metadata?.type || item.metadata.type === "server-wallet") + ); + }) + .map((item) => ({ + id: item.id, + address: item.address, + label: item.metadata?.label ?? undefined, + })); + } catch (error) { + console.error("Failed to list project server wallets", error); + return []; + } +} diff --git a/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts b/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts new file mode 100644 index 00000000000..6cb4875fb94 --- /dev/null +++ b/apps/dashboard/src/@/actions/project-wallet/send-tokens.ts @@ -0,0 +1,74 @@ +"use server"; + +import { configure, sendTokens } from "@thirdweb-dev/api"; +import { THIRDWEB_API_HOST } from "@/constants/urls"; + +configure({ + override: { + baseUrl: THIRDWEB_API_HOST, + }, +}); + +export async function sendProjectWalletTokens(options: { + walletAddress: string; + recipientAddress: string; + chainId: number; + quantityWei: string; + publishableKey: string; + teamId: string; + tokenAddress?: string; + secretKey: string; + vaultAccessToken?: string; +}) { + const { + walletAddress, + recipientAddress, + chainId, + quantityWei, + publishableKey, + teamId, + tokenAddress, + secretKey, + vaultAccessToken, + } = options; + + if (!secretKey) { + return { + error: "A project secret key is required to send funds.", + ok: false, + } as const; + } + + const response = await sendTokens({ + body: { + chainId, + from: walletAddress, + recipients: [ + { + address: recipientAddress, + quantity: quantityWei, + }, + ], + ...(tokenAddress ? { tokenAddress } : {}), + }, + headers: { + "Content-Type": "application/json", + "x-client-id": publishableKey, + "x-secret-key": secretKey, + "x-team-id": teamId, + ...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}), + }, + }); + + if (response.error || !response.data) { + return { + error: response.error || "Failed to submit transfer request.", + ok: false, + } as const; + } + + return { + ok: true, + transactionIds: response.data.result?.transactionIds ?? [], + } as const; +} diff --git a/apps/dashboard/src/@/actions/proxies.ts b/apps/dashboard/src/@/actions/proxies.ts index 0d5551689d3..b9c4fab51d1 100644 --- a/apps/dashboard/src/@/actions/proxies.ts +++ b/apps/dashboard/src/@/actions/proxies.ts @@ -1,17 +1,23 @@ "use server"; -import { getAuthToken } from "../../app/api/lib/getAuthToken"; -import { API_SERVER_URL } from "../constants/env"; +import { getAuthToken } from "@/api/auth-token"; +import { + NEXT_PUBLIC_ENGINE_CLOUD_URL, + NEXT_PUBLIC_THIRDWEB_API_HOST, +} from "@/constants/public-envs"; +import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs"; type ProxyActionParams = { pathname: string; - searchParams?: Record; + searchParams?: Record; method: "GET" | "POST" | "PUT" | "DELETE"; body?: string; headers?: Record; + parseAsText?: boolean; + signal?: AbortSignal; }; -type ProxyActionResult = +type ProxyActionResult = | { status: number; ok: true; @@ -23,7 +29,7 @@ type ProxyActionResult = error: string; }; -async function proxy( +async function proxy( baseUrl: string, params: ProxyActionParams, ): Promise> { @@ -34,65 +40,54 @@ async function proxy( url.pathname = params.pathname; if (params.searchParams) { for (const key in params.searchParams) { - url.searchParams.append(key, params.searchParams[key] as string); + const value = params.searchParams[key]; + if (value) { + url.searchParams.append(key, value); + } } } const res = await fetch(url, { - method: params.method, + body: params.body, headers: { ...params.headers, ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), }, - body: params.body, + method: params.method, }); if (!res.ok) { try { const errorMessage = await res.text(); return { - status: res.status, - ok: false, error: errorMessage || res.statusText, + ok: false, + status: res.status, }; } catch { return { - status: res.status, - ok: false, error: res.statusText, + ok: false, + status: res.status, }; } } return { - status: res.status, + data: params.parseAsText ? await res.text() : await res.json(), ok: true, - data: await res.json(), + status: res.status, }; } -export async function analyticsServerProxy( - params: ProxyActionParams, -) { - return proxy( - process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com", - params, - ); +export async function apiServerProxy(params: ProxyActionParams) { + return proxy(NEXT_PUBLIC_THIRDWEB_API_HOST, params); } -export async function apiServerProxy( - params: ProxyActionParams, -) { - return proxy(API_SERVER_URL, params); +export async function engineCloudProxy(params: ProxyActionParams) { + return proxy(NEXT_PUBLIC_ENGINE_CLOUD_URL, params); } -export async function payServerProxy( - params: ProxyActionParams, -) { - return proxy( - process.env.NEXT_PUBLIC_PAY_URL - ? `https://${process.env.NEXT_PUBLIC_PAY_URL}` - : "https://pay.thirdweb-dev.com", - params, - ); +export async function analyticsServerProxy(params: ProxyActionParams) { + return proxy(ANALYTICS_SERVICE_URL, params); } diff --git a/apps/dashboard/src/@/actions/revalidate.ts b/apps/dashboard/src/@/actions/revalidate.ts new file mode 100644 index 00000000000..25e4f8d950a --- /dev/null +++ b/apps/dashboard/src/@/actions/revalidate.ts @@ -0,0 +1,14 @@ +"use server"; + +import { revalidatePath, revalidateTag } from "next/cache"; + +export async function revalidatePathAction( + path: string, + type: "page" | "layout", +) { + revalidatePath(path, type); +} + +export async function revalidateCacheTagAction(tag: string) { + revalidateTag(tag); +} diff --git a/apps/dashboard/src/@/actions/stripe-actions.ts b/apps/dashboard/src/@/actions/stripe-actions.ts new file mode 100644 index 00000000000..acb6761a463 --- /dev/null +++ b/apps/dashboard/src/@/actions/stripe-actions.ts @@ -0,0 +1,140 @@ +import "server-only"; + +import { cookies, headers } from "next/headers"; +import Stripe from "stripe"; +import type { Team } from "@/api/team/get-team"; +import { + GROWTH_PLAN_SKU, + PAYMENT_METHOD_CONFIGURATION, + STRIPE_SECRET_KEY, +} from "@/constants/server-envs"; + +let existingStripe: Stripe | undefined; + +function getStripe() { + if (!existingStripe) { + 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; status?: "open" }, +) { + 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, + // Only return open invoices if the status is open + status: options?.status, + }); + + 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"); + } +} + +async function getStripeCustomer(customerId: string) { + return await getStripe().customers.retrieve(customerId); +} + +export async function getStripeBalance(customerId: string) { + const customer = await getStripeCustomer(customerId); + if (customer.deleted) { + return 0; + } + // Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0) + return customer.balance === 0 ? 0 : customer.balance / -100; +} + +export async function fetchClientSecret(team: Team) { + "use server"; + const origin = (await headers()).get("origin"); + const stripe = getStripe(); + const customerId = team.stripeCustomerId; + + if (!customerId) { + throw new Error("No customer ID found"); + } + + // try to get the gclid cookie + const gclid = (await cookies()).get("gclid")?.value; + + // Create Checkout Sessions from body params. + const session = await stripe.checkout.sessions.create({ + ui_mode: "embedded", + line_items: [ + { + // Provide the exact Price ID (for example, price_1234) of + // the product you want to sell + price: GROWTH_PLAN_SKU, + quantity: 1, + }, + ], + mode: "subscription", + + return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`, + automatic_tax: { enabled: true }, + allow_promotion_codes: true, + customer: customerId, + customer_update: { + address: "auto", + }, + payment_method_collection: "always", + payment_method_configuration: PAYMENT_METHOD_CONFIGURATION, + subscription_data: { + trial_period_days: 14, + trial_settings: { + end_behavior: { + missing_payment_method: "cancel", + }, + }, + // if gclid exists, set it as a metadata field so we can attribute the conversion later + metadata: gclid ? { gclid } : undefined, + }, + }); + + if (!session.client_secret) { + throw new Error("No client secret found"); + } + + return session.client_secret; +} + +export async function getStripeSessionById(sessionId: string) { + const session = await getStripe().checkout.sessions.retrieve(sessionId, { + expand: ["line_items", "payment_intent"], + }); + return session; +} diff --git a/apps/dashboard/src/@/actions/team/acceptInvite.ts b/apps/dashboard/src/@/actions/team/acceptInvite.ts new file mode 100644 index 00000000000..8621caee41c --- /dev/null +++ b/apps/dashboard/src/@/actions/team/acceptInvite.ts @@ -0,0 +1,53 @@ +"use server"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export async function acceptInvite(options: { + teamId: string; + inviteId: string; +}) { + const token = await getAuthToken(); + + if (!token) { + return { + errorMessage: "You are not authorized to perform this action", + ok: false, + }; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/invites/${options.inviteId}/accept`, + { + body: JSON.stringify({}), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!res.ok) { + let errorMessage = "Failed to accept invite"; + try { + const result = (await res.json()) as { + error: { + code: string; + message: string; + statusCode: number; + }; + }; + errorMessage = result.error.message; + } catch {} + + return { + errorMessage, + ok: false, + }; + } + + return { + ok: true, + }; +} diff --git a/apps/dashboard/src/@/actions/team/createTeam.ts b/apps/dashboard/src/@/actions/team/createTeam.ts new file mode 100644 index 00000000000..c75ed24282a --- /dev/null +++ b/apps/dashboard/src/@/actions/team/createTeam.ts @@ -0,0 +1,72 @@ +"use server"; +import "server-only"; + +// biome-ignore lint/style/useNodejsImportProtocol: breaks storybook if it's `node:` prefixed +import { randomBytes } from "crypto"; +import { format } from "date-fns"; +import { getAuthToken } from "@/api/auth-token"; +import type { Team } from "@/api/team/get-team"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export async function createTeam(options?: { name?: string; slug?: string }) { + const token = await getAuthToken(); + + if (!token) { + return { + errorMessage: "You are not authorized to perform this action", + status: "error", + } as const; + } + + const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, { + body: JSON.stringify({ + billingEmail: null, + image: null, + name: + options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`, + slug: options?.slug ?? randomBytes(20).toString("hex"), + }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (!res.ok) { + const reason = await res.text(); + console.error("failed to create team", { + reason, + status: res.status, + }); + switch (res.status) { + case 400: { + return { + errorMessage: "Invalid team name or slug.", + status: "error", + } as const; + } + case 401: { + return { + errorMessage: "You are not authorized to perform this action.", + status: "error", + } as const; + } + default: { + return { + errorMessage: "An unknown error occurred.", + status: "error", + } as const; + } + } + } + + const json = (await res.json()) as { + result: Team; + }; + + return { + data: json.result, + status: "success", + } as const; +} diff --git a/apps/dashboard/src/@/actions/team/deleteTeam.ts b/apps/dashboard/src/@/actions/team/deleteTeam.ts new file mode 100644 index 00000000000..628a343fe6b --- /dev/null +++ b/apps/dashboard/src/@/actions/team/deleteTeam.ts @@ -0,0 +1,68 @@ +"use server"; +import "server-only"; +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export async function deleteTeam(options: { teamId: string }) { + const token = await getAuthToken(); + if (!token) { + return { + errorMessage: "You are not authorized to perform this action.", + status: "error", + } as const; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + method: "DELETE", + }, + ); + // handle errors + if (!res.ok) { + const reason = await res.text(); + console.error("failed to delete team", { + reason, + status: res.status, + }); + switch (res.status) { + case 400: { + return { + errorMessage: "Invalid team ID.", + status: "error", + } as const; + } + case 401: { + return { + errorMessage: "You are not authorized to perform this action.", + status: "error", + } as const; + } + + case 403: { + return { + errorMessage: "You do not have permission to delete this team.", + status: "error", + } as const; + } + case 404: { + return { + errorMessage: "Team not found.", + status: "error", + } as const; + } + default: { + return { + errorMessage: "An unknown error occurred.", + status: "error", + } as const; + } + } + } + return { + status: "success", + } as const; +} diff --git a/apps/dashboard/src/@/actions/team/sendTeamInvite.ts b/apps/dashboard/src/@/actions/team/sendTeamInvite.ts new file mode 100644 index 00000000000..b9842587c49 --- /dev/null +++ b/apps/dashboard/src/@/actions/team/sendTeamInvite.ts @@ -0,0 +1,71 @@ +"use server"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export async function sendTeamInvites(options: { + teamId: string; + invites: Array<{ email: string; role: "OWNER" | "MEMBER" }>; +}): Promise< + | { + ok: true; + results: Array<"fulfilled" | "rejected">; + } + | { + ok: false; + errorMessage: string; + } +> { + const token = await getAuthToken(); + + if (!token) { + return { + errorMessage: "You are not authorized to perform this action", + ok: false, + }; + } + + const results = await Promise.allSettled( + options.invites.map((invite) => sendInvite(options.teamId, invite, token)), + ); + + return { + ok: true, + results: results.map((x) => x.status), + }; +} + +async function sendInvite( + teamId: string, + invite: { email: string; role: "OWNER" | "MEMBER" }, + token: string, +) { + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamId}/invites`, + { + body: JSON.stringify({ + inviteEmail: invite.email, + inviteRole: invite.role, + }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!res.ok) { + const errorMessage = await res.text(); + return { + email: invite.email, + errorMessage, + ok: false, + }; + } + + return { + email: invite.email, + ok: true, + }; +} diff --git a/apps/dashboard/src/@/actions/validLogin.ts b/apps/dashboard/src/@/actions/validLogin.ts deleted file mode 100644 index 03d637d2b06..00000000000 --- a/apps/dashboard/src/@/actions/validLogin.ts +++ /dev/null @@ -1,48 +0,0 @@ -"use server"; - -import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; -import { cookies } from "next/headers"; -import { getAddress } from "thirdweb"; -import { COOKIE_PREFIX_TOKEN } from "../constants/cookie"; -import { API_SERVER_URL } from "../constants/env"; - -/** - * Check that the connected wallet is valid for the current active account - */ -export async function isWalletValidForActiveAccount(params: { - address: string; - account: Account; - authToken: string; -}) { - const cookieStore = await cookies(); - const authCookieNameForAddress = - COOKIE_PREFIX_TOKEN + getAddress(params.address); - - // authToken for this wallet address should be present - const authTokenForAddress = cookieStore.get(authCookieNameForAddress)?.value; - if (!authTokenForAddress) { - return false; - } - - // this authToken should be same as current active authToken - if (authTokenForAddress !== params.authToken) { - return false; - } - - // authToken should be valid - const accountRes = await fetch(`${API_SERVER_URL}/v1/account/me`, { - method: "GET", - headers: { - Authorization: `Bearer ${authTokenForAddress}`, - }, - }); - - if (accountRes.status !== 200) { - return false; - } - - const account = (await accountRes.json()).data as Account; - - // the current account should match the account fetched for the authToken - return account.id === params.account.id; -} diff --git a/apps/dashboard/src/@/analytics/README.md b/apps/dashboard/src/@/analytics/README.md new file mode 100644 index 00000000000..74e766f6e33 --- /dev/null +++ b/apps/dashboard/src/@/analytics/README.md @@ -0,0 +1,67 @@ +# Analytics Guidelines + +This folder centralises the **PostHog** tracking logic for the dashboard app. +Most developers will only need to add or extend _event-reporting_ functions in `report.ts`. + +--- + +## 1. When to add an event +1. Ask yourself if the data will be **actionable**. Every event should have a clear product or business question it helps answer. +2. Check if a similar event already exists in `report.ts`. Avoid duplicates. + +--- + +## 2. Naming conventions +| Concept | Convention | Example | +|---------|------------|---------| +| **Event name** (string sent to PostHog) | Human-readable phrase formatted as ` ` | `"contract deployed"` | +| **Reporting function** | `report` (PascalCase) | `reportContractDeployed` | +| **File** | All event functions live in the shared `report.ts` file (for now) | — | + +> Keeping names predictable makes it easy to search both code and analytics. + +--- + +## 3. Boilerplate / template +Add a new function to `report.ts` following this pattern: + +```ts +/** + * ### Why do we need to report this event? + * - _Add bullet points explaining the product metrics/questions this event answers._ + * + * ### Who is responsible for this event? + * @your-github-handle + */ +export function reportExampleEvent(properties: { + /* Add typed properties here */ +}) { + posthog.capture("example event", { + /* Pass the same properties here */ + }); +} +``` + +Guidelines: +1. **Explain the "why".** The JSDoc block is mandatory so future contributors know the purpose. +2. **Type everything.** The `properties` object should be fully typed—this doubles as documentation. +3. **Client-side only.** `posthog-js` must never run on the server. Call these reporting helpers from client components, event handlers, etc. + +--- + +## 4. Editing or removing events +1. Update both the function and the PostHog event definition (if required). +2. Inform the core services team before removing or renaming an event. + +--- + +## 5. Identification & housekeeping (FYI) +Most devs can ignore this section, but for completeness: + +- `hooks/identify-account.ts` and `hooks/identify-team.ts` wrap `posthog.identify`/`group` calls. +- `resetAnalytics` clears identity state (used on logout). + +--- + +## 6. Need help? +Ping #eng-core-services in slack. diff --git a/apps/dashboard/src/@/analytics/hooks/identify-account.ts b/apps/dashboard/src/@/analytics/hooks/identify-account.ts new file mode 100644 index 00000000000..34a9b0122af --- /dev/null +++ b/apps/dashboard/src/@/analytics/hooks/identify-account.ts @@ -0,0 +1,20 @@ +"use client"; + +import posthog from "posthog-js"; +import { useEffect } from "react"; + +export function useIdentifyAccount(opts?: { + accountId: string; + email?: string; +}) { + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // if no accountId, don't identify + if (!opts?.accountId) { + return; + } + + // if email is provided, add it to the identify + posthog.identify(opts.accountId, opts.email ? { email: opts.email } : {}); + }, [opts?.accountId, opts?.email]); +} diff --git a/apps/dashboard/src/@/analytics/hooks/identify-team.ts b/apps/dashboard/src/@/analytics/hooks/identify-team.ts new file mode 100644 index 00000000000..489ca357bd6 --- /dev/null +++ b/apps/dashboard/src/@/analytics/hooks/identify-team.ts @@ -0,0 +1,17 @@ +"use client"; + +import posthog from "posthog-js"; +import { useEffect } from "react"; + +export function useIdentifyTeam(opts?: { teamId: string }) { + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // if no teamId, don't identify + if (!opts?.teamId) { + return; + } + + // identify the team + posthog.group("team", opts.teamId); + }, [opts?.teamId]); +} diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts new file mode 100644 index 00000000000..c054a150872 --- /dev/null +++ b/apps/dashboard/src/@/analytics/report.ts @@ -0,0 +1,704 @@ +"use client"; +import posthog from "posthog-js"; + +import type { Team } from "@/api/team/get-team"; +import type { ProductSKU } from "../types/billing"; + +// ---------------------------- +// CONTRACTS +// ---------------------------- + +/** + * ### Why do we need to report this event? + * - To track the number of contracts deployed + * - To track the number of contracts deployed on each chain + * - To track if the contract was deployed on the token page vs on the deploy page + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportContractDeployed(properties: { + address: string; + chainId: number; + publisher: string | undefined; + contractName: string | undefined; + deploymentType?: "asset"; + is_testnet: boolean | undefined; +}) { + posthog.capture("contract deployed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track the number of contracts that failed to deploy + * - To track the error message of the failed contract deployment (so we can fix it / add workarounds) + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportContractDeployFailed(properties: { + errorMessage: string; + chainId: number; + is_testnet: boolean | undefined; + publisher: string | undefined; + contractName: string | undefined; +}) { + posthog.capture("contract deploy failed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track the number of contracts published + * - To understand the type of contracts published + * - To understand who publishes contracts + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportContractPublished(properties: { + publisher: string; + contractName: string; + version: string; + deployType: string | undefined; +}) { + posthog.capture("contract published", properties); +} + +// ---------------------------- +// ONBOARDING (TEAM) +// ---------------------------- + +/** + * ### Why do we need to report this event? + * - To track the number of teams that enter the onboarding flow + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingStarted() { + posthog.capture("onboarding started"); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that invite members during onboarding + * - To track **how many** members were invited + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersInvited(properties: { count: number }) { + posthog.capture("onboarding members invited", { + count: properties.count, + }); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that skip inviting members during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersSkipped() { + posthog.capture("onboarding members skipped"); +} + +/** + * ### Why do we need to report this event? + * - To track how many teams click the upsell (upgrade) button on the member-invite step during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersUpsellButtonClicked() { + posthog.capture("onboarding members upsell clicked"); +} + +/** + * ### Why do we need to report this event? + * - To track which plan is selected from the members-step upsell during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersUpsellPlanSelected(properties: { + plan: Team["billingPlan"]; +}) { + posthog.capture("onboarding members upsell plan selected", properties); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that completed the team member step during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportTeamMemberStepCompleted() { + posthog.capture("onboarding members completed"); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that completed onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingCompleted() { + posthog.capture("onboarding completed"); +} + +// ---------------------------- +// FAUCET +// ---------------------------- +/** + * ### Why do we need to report this event? + * - To track which chain the faucet was used on + * - To track how popular specific faucets are + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportFaucetUsed(properties: { chainId: number }) { + posthog.capture("faucet used", properties); +} + +// ---------------------------- +// CHAIN CONFIGURATION +// ---------------------------- +/** + * ### Why do we need to report this event? + * - To track which custom chains customers are adding that we may want to add to the app + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportChainConfigurationAdded(properties: { + chainId: number; + chainName: string; + rpcURLs: readonly string[]; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + }; +}) { + posthog.capture("chain configuration added", properties); +} + +// ---------------------------- +// ASSETS +// ---------------------------- + +type AssetContractType = + | "DropERC20" + | "DropERC1155" + | "DropERC721" + | "ERC20Asset"; + +/** + * ### Why do we need to report this event? + * - To track number of successful asset purchases from the token page + * - To track which asset and contract types are being purchased the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetBuySuccessful(properties: { + chainId: number; + contractType: AssetContractType | undefined; + assetType: "nft" | "coin"; + is_testnet: boolean | undefined; +}) { + posthog.capture("asset buy successful", properties); +} + +type TokenSwapParams = { + buyTokenChainId: number; + buyTokenAddress: string; + sellTokenChainId: number; + sellTokenAddress: string; + pageType: "asset" | "bridge" | "chain" | "bridge-iframe"; +}; + +type TokenBuyParams = { + buyTokenChainId: number | undefined; + buyTokenAddress: string | undefined; + pageType: "asset" | "bridge" | "chain" | "bridge-iframe"; +}; + +/** + * ### Why do we need to report this event? + * - To track number of successful token buys + * - To track which tokens are being bought the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenBuySuccessful(properties: TokenBuyParams) { + posthog.capture("token buy successful", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of failed token buys + * - To track which token buys are failing + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenBuyFailed(properties: TokenBuyParams) { + posthog.capture("token buy failed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of cancelled token buys + * - To track which token buys are being cancelled + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenBuyCancelled(properties: TokenBuyParams) { + posthog.capture("token buy cancelled", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of successful token swaps + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapSuccessful(properties: TokenSwapParams) { + posthog.capture("token swap successful", properties); +} + +/** + * ### Why do we need to report this event? + * - To track impressions of the swap widget + * - To create a funnel "swap widget shown" -> "swap widget successful" to understand the conversion rate + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportSwapWidgetShown(properties: { + pageType: "asset" | "bridge" | "chain" | "bridge-iframe"; +}) { + posthog.capture("swap widget shown", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of failed token swaps + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapFailed( + properties: TokenSwapParams & { + errorMessage: string; + }, +) { + posthog.capture("token swap failed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of cancelled token swaps + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapCancelled(properties: TokenSwapParams) { + posthog.capture("token swap cancelled", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of failed asset purchases + * - To track the errors that users encounter when trying to purchase an asset + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetBuyFailed(properties: { + chainId: number; + is_testnet: boolean | undefined; + contractType: AssetContractType | undefined; + assetType: "nft" | "coin"; + error: string; +}) { + posthog.capture("asset buy failed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of cancelled asset purchases from the token page + * - To track the errors that users encounter when trying to purchase an asset + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetBuyCancelled(properties: { + chainId: number; + is_testnet: boolean | undefined; + contractType: AssetContractType | undefined; + assetType: "nft" | "coin"; +}) { + posthog.capture("asset buy cancelled", properties); +} + +// Assets Landing Page ---------------------------- + +/** + * ### Why do we need to report this event? + * - To track number of assets imported successfully from the assets page + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetImportSuccessful() { + posthog.capture("asset import successful"); +} + +/** + * ### Why do we need to report this event? + * - To track number of asset import started in the assets page + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetImportStarted() { + posthog.capture("asset import started"); +} + +/** + * ### Why do we need to report this event? + * - To track the steps users are configuring in the asset creation to understand if there are any drop-offs + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetCreationStepConfigured( + properties: + | { + assetType: "nft"; + step: "collection-info" | "upload-assets" | "sales-settings"; + } + | { + assetType: "coin"; + step: "coin-info" | "token-distribution" | "launch-coin"; + }, +) { + posthog.capture("asset creation step configured", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of successful asset creations + * - To track which asset types are being created the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetCreationSuccessful(properties: { + assetType: "nft" | "coin"; + contractType: AssetContractType; + chainId: number; + is_testnet: boolean | undefined; +}) { + posthog.capture("asset creation successful", properties); +} + +type CoinCreationStep = + | "erc20-asset:deploy-contract" + | "erc20-asset:airdrop-tokens" + | "erc20-asset:approve-airdrop-tokens" + | "drop-erc20:deploy-contract" + | "drop-erc20:set-claim-conditions" + | "drop-erc20:mint-tokens" + | "drop-erc20:airdrop-tokens"; + +/** + * ### Why do we need to report this event? + * - To track number of failed asset creations + * - To track the errors that users encounter when trying to create an asset + * - To track the step that is failing in the asset creation + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetCreationFailed( + properties: { + contractType: AssetContractType; + error: string; + is_testnet: boolean | undefined; + chainId: number; + } & ( + | { + assetType: "nft"; + step: + | "deploy-contract" + | "mint-nfts" + | "set-claim-conditions" + | "set-admins"; + } + | { + assetType: "coin"; + step: CoinCreationStep; + } + ), +) { + posthog.capture("asset creation failed", properties); +} + +type UpsellParams = { + content: "storage-limit"; + campaign: "create-coin" | "create-nft"; + sku: Exclude; +}; + +/** + * ### Why do we need to report this event? + * - To track how effective the upsells are in driving users to upgrade + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportUpsellShown(properties: UpsellParams) { + posthog.capture("upsell shown", properties); +} + +/** + * ### Why do we need to report this event? + * - To track how effective the upsells are in driving users to upgrade + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportUpsellClicked(properties: UpsellParams) { + posthog.capture("upsell clicked", properties); +} + +// ---------------------------- +// PAYMENTS +// ---------------------------- + +/** + * ### Why do we need to report this event? + * - To track conversions on payment overview page + * + * ### Who is responsible for this event? + * @samina + */ +export function reportPaymentCardClick(properties: { id: string }) { + posthog.capture("payment card clicked", properties); +} + +/** + * ### Why do we need to report this event? + * - To create a funnel "create payment link pageview" -> "payment link created" to understand the conversion rate + * + * ### Who is responsible for this event? + * @greg + */ +export function reportPaymentLinkCreated(properties: { + linkId: string; + clientId: string; +}) { + posthog.capture("payment link created", properties); +} + +/** + * ### Why do we need to report this event? + * - To track funnel "payment link pageview" -> "payment link buy successful" to understand the conversion rate + * + * ### Who is responsible for this event? + * @greg + */ +export function reportPaymentLinkBuySuccessful() { + posthog.capture("payment link buy successful"); +} + +/** + * ### Why do we need to report this event? + * - To track the number of failed payment link buys + * - To track what errors users encounter when trying to buy from a payment link + * + * ### Who is responsible for this event? + * @greg + */ +export function reportPaymentLinkBuyFailed(properties: { + errorMessage: string; +}) { + posthog.capture("payment link buy failed", properties); +} + +/** + * ### Why do we need to report this event? + * - To create a funnel for "asset pageview" -> "asset purchase successful" to understand the conversion rate + * - To understand which asset types are being viewed the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetPageview(properties: { + assetType: "nft" | "coin"; + chainId: number; + is_testnet: boolean | undefined; +}) { + posthog.capture("asset pageview", properties); +} + +/** + * ### Why do we need to report this event? + * - To understand which chains are being viewed the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportChainPageview(properties: { + chainId: number; + is_testnet: boolean | undefined; +}) { + posthog.capture("chain pageview", properties); +} + +/** + * ### Why do we need to report this event? + * - To track the usage of fund wallet modal + * - To create a funnel "fund wallet modal opened" -> "fund wallet buy successful" to understand the conversion rate + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportFundWalletOpened() { + posthog.capture("fund wallet opened"); +} + +/** + * ### Why do we need to report this event? + * - To track the number of successful fund wallet buys + * - To create a funnel "fund wallet modal opened" -> "fund wallet buy successful" to understand the conversion rate + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportFundWalletSuccessful() { + posthog.capture("fund wallet successful"); +} + +/** + * ### Why do we need to report this event? + * - To track the number of failed fund wallet buys + * - To track the errors that users encounter when trying to buy from a fund wallet modal + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportFundWalletFailed(params: { errorMessage: string }) { + posthog.capture("fund wallet failed", params); +} + +/** + * ### Why do we need to report this event? + * - To track the conversion rate of the users choosing to create a token from new flow instead of the old flow + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenUpsellClicked(params: { + assetType: "nft" | "coin"; + pageType: "explore" | "deploy-contract"; +}) { + posthog.capture("token upsell clicked", params); +} + +// ---------------------------- +// CHAIN INFRASTRUCTURE CHECKOUT +// ---------------------------- +/** + * ### Why do we need to report this event? + * - To record explicit user acknowledgement when proceeding to checkout without RPC + * - To measure how often customers choose to omit RPC while purchasing Insight and/or Account Abstraction + * - To correlate potential support issues arising from missing RPC + * + * ### Who is responsible for this event? + * @jnsdls + */ +export function reportChainInfraRpcOmissionAgreed(properties: { + chainId: number; + frequency: "monthly" | "annual"; + includeInsight: boolean; + includeAccountAbstraction: boolean; +}) { + posthog.capture("chain infra checkout rpc omission agreed", properties); +} + +// ---------------------------- +// FEEDBACK +// ---------------------------- + +/** + * ### Why do we need to report this event? + * - To track user feedback and sentiment about the product + * - To identify common issues or feature requests + * - To measure user satisfaction and engagement + * - To prioritize product improvements based on user input + * + * ### Who is responsible for this event? + * @gisellechacon + */ +export function reportProductFeedback(properties: { + feedback: string; + source: "desktop" | "mobile"; +}) { + posthog.capture("product feedback submitted", properties); +} + +/** + * ### Why do we need to report this event? + * - To track conversions for the bridge page links + * + * ### Who is responsible for this event? + * @MananTank + * + */ +export function reportBridgePageLinkClick(params: { + linkType: "bridge-docs" | "trending-tokens" | "integrate-bridge-widget"; +}) { + posthog.capture("bridge page link clicked", params); +} + +/** + * ### Why do we need to report this event? + * - To track which tokens are users are clicking on in the token list page + * + * ### Who is responsible for this event? + * @MananTank + * + */ +export function reportTokenRowClicked(params: { + chainId: number; + tokenAddress: string; +}) { + posthog.capture("token row clicked", params); +} diff --git a/apps/dashboard/src/@/analytics/reset.ts b/apps/dashboard/src/@/analytics/reset.ts new file mode 100644 index 00000000000..61f4b0acf40 --- /dev/null +++ b/apps/dashboard/src/@/analytics/reset.ts @@ -0,0 +1,7 @@ +"use client"; + +import posthog from "posthog-js"; + +export function resetAnalytics() { + posthog.reset(); +} diff --git a/apps/dashboard/src/@/api/account/get-account.ts b/apps/dashboard/src/@/api/account/get-account.ts new file mode 100644 index 00000000000..1cb2b4fb008 --- /dev/null +++ b/apps/dashboard/src/@/api/account/get-account.ts @@ -0,0 +1,53 @@ +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import type { Account } from "@/hooks/useApi"; +import { isAccountOnboardingComplete } from "@/utils/account-onboarding"; +import { loginRedirect } from "@/utils/redirects"; + +/** + * Just get the account object without enforcing onboarding. + * In most cases - you should just be using `getValidAccount` + */ +export async function getRawAccount() { + const authToken = await getAuthToken(); + + if (!authToken) { + return undefined; + } + + const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account/me`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + }); + + if (!res.ok) { + console.error("Error fetching account", res.status, res.statusText); + return undefined; + } + + const json = await res.json(); + + if (json.error) { + console.error(json.error); + return undefined; + } + + return json.data as Account; +} + +/** + * If there's no account or account onboarding not complete, redirect to login page + * @param pagePath - the path of the current page to redirect back to after login/onboarding + */ +export async function getValidAccount(pagePath?: string) { + const account = await getRawAccount(); + + // enforce login & onboarding + if (!account || !isAccountOnboardingComplete(account)) { + loginRedirect(pagePath); + } + + return account; +} diff --git a/apps/dashboard/src/@/api/account/linked-wallets.ts b/apps/dashboard/src/@/api/account/linked-wallets.ts new file mode 100644 index 00000000000..fcc85d0b262 --- /dev/null +++ b/apps/dashboard/src/@/api/account/linked-wallets.ts @@ -0,0 +1,35 @@ +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export type LinkedWallet = { + createdAt: string; + id: string; + walletAddress: string; +}; + +export async function getLinkedWallets() { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account/wallets`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (res.ok) { + const json = (await res.json()) as { + data: LinkedWallet[]; + }; + + return json.data; + } + + return null; +} diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index dd4fb03cd29..2b6969a8181 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -1,22 +1,116 @@ -import { fetchAnalytics } from "data/analytics/fetch-analytics"; +import "server-only"; + +import { unstable_cache } from "next/cache"; +import { getWalletInfo, type WalletId } from "thirdweb/wallets"; +import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs"; +import { normalizeTime } from "@/lib/time"; import type { + AIUsageStats, AnalyticsQueryParams, + EcosystemWalletStats, InAppWalletStats, - RpcMethodStats, TransactionStats, + UniversalBridgeStats, + UniversalBridgeWalletStats, UserOpStats, WalletStats, - WalletUserStats, -} from "types/analytics"; + WalletStatsWithName, + WebhookLatencyStats, + WebhookSummaryStats, + X402QueryParams, + X402SettlementStats, +} from "@/types/analytics"; +import { getChains } from "./chain"; + +export interface InsightChainStats { + date: string; + chainId: string; + totalRequests: number; +} + +export interface InsightStatusCodeStats { + date: string; + httpStatusCode: number; + totalRequests: number; +} + +export interface InsightEndpointStats { + date: string; + endpoint: string; + totalRequests: number; +} + +interface InsightUsageStats { + date: string; + totalRequests: number; +} + +export interface RpcMethodStats { + date: string; + evmMethod: string; + count: number; +} + +export interface RpcUsageTypeStats { + date: string; + usageType: string; + count: number; +} + +function normalizedParams(params: T): T { + return { + ...params, + from: params.from ? normalizeTime(params.from) : undefined, + to: params.to ? normalizeTime(params.to) : undefined, + }; +} + +async function fetchAnalytics(params: { + authToken: string; + url: string | URL; + init?: RequestInit; +}): Promise { + const [pathname, searchParams] = params.url.toString().split("?"); + if (!pathname) { + throw new Error("Invalid input, no pathname provided"); + } + + // create a new URL object for the analytics server + const analyticsServiceUrl = new URL( + ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com", + ); + + analyticsServiceUrl.pathname = pathname; + for (const param of searchParams?.split("&") || []) { + const [key, value] = param.split("="); + if (!key || !value) { + throw new Error("Invalid input, no key or value provided"); + } + analyticsServiceUrl.searchParams.append( + decodeURIComponent(key), + decodeURIComponent(value), + ); + } + + return fetch(analyticsServiceUrl, { + ...params.init, + headers: { + Authorization: `Bearer ${params.authToken}`, + ...params.init?.headers, + }, + }); +} function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams { const searchParams = new URLSearchParams(); - if (params.clientId) { - searchParams.append("clientId", params.clientId); - } - if (params.accountId) { - searchParams.append("accountId", params.accountId); + + searchParams.append("teamId", params.teamId); + + if (params.projectId) { + searchParams.append("projectId", params.projectId); } + + // shared params if (params.from) { searchParams.append("from", params.from.toISOString()); } @@ -26,149 +120,923 @@ function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams { if (params.period) { searchParams.append("period", params.period); } + if (params.limit) { + searchParams.append("limit", params.limit.toString()); + } return searchParams; } -export async function getWalletConnections( - params: AnalyticsQueryParams, -): Promise { - const searchParams = buildSearchParams(params); - const res = await fetchAnalytics(`v1/wallets?${searchParams.toString()}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); +const cached_getWalletConnections = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + const res = await fetchAnalytics({ + authToken, + url: `v2/sdk/wallet-connects?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); - if (res?.status !== 200) { - console.error("Failed to fetch wallet connections"); - return []; - } + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch wallet connections, ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as WalletStats[]; + }, + ["getWalletConnections"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +const cache_getEOAWalletConnections = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const result = await cached_getWalletConnections( + normalizedParams(params), + authToken, + ); + const filteredResult = result.filter( + (w) => + w.walletType !== "smart" && + w.walletType !== "smartWallet" && + w.walletType !== "inApp" && + w.walletType !== "inAppWallet", + ); + + const modifiedResult = await Promise.all( + filteredResult.map(async (w) => { + const wallet = await getWalletInfo(w.walletType as WalletId).catch( + () => undefined, + ); - const json = await res.json(); - return json.data as WalletStats[]; + if (!wallet) { + return { + ...w, + walletName: w.walletType, + }; + } + + return { + ...w, + walletName: wallet.name, + }; + }), + ); + + return modifiedResult; + }, + ["getEOAWalletConnections"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export async function getEOAWalletConnections( + params: AnalyticsQueryParams, + authToken: string, +) { + return cache_getEOAWalletConnections(normalizedParams(params), authToken); } -export async function getInAppWalletUsage( +const cache_getEOAAndInAppWalletConnections = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const result = await cached_getWalletConnections( + normalizedParams(params), + authToken, + ); + + const modifiedResult = await Promise.all( + result.map(async (w) => { + if ( + w.walletType === "inApp" || + w.walletType === "inAppWallet" || + w.walletType === "smart" || + w.walletType === "smartWallet" + ) { + return { + ...w, + walletName: "User wallet", + }; + } + + const wallet = await getWalletInfo(w.walletType as WalletId).catch( + () => undefined, + ); + + if (!wallet) { + return { + ...w, + walletName: w.walletType, + }; + } + + return { + ...w, + walletName: wallet.name, + }; + }), + ); + + return modifiedResult; + }, + ["getEOAAndInAppWalletConnections"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export async function getEOAAndInAppWalletConnections( params: AnalyticsQueryParams, -): Promise { - const searchParams = buildSearchParams(params); - const res = await fetchAnalytics( - `v1/wallets/in-app?${searchParams.toString()}`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - }, + authToken: string, +): Promise { + return cache_getEOAAndInAppWalletConnections( + normalizedParams(params), + authToken, ); +} - if (res?.status !== 200) { - console.error("Failed to fetch in-app wallet usage"); - return []; - } +const cached_getInAppWalletUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + const res = await fetchAnalytics({ + authToken, + url: `v2/wallet/connects?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch user wallets usage, ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as InAppWalletStats[]; + }, + ["getInAppWalletUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getInAppWalletUsage( + params: AnalyticsQueryParams, + authToken: string, +) { + return cached_getInAppWalletUsage(normalizedParams(params), authToken); +} + +const cached_getAiUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + const res = await fetchAnalytics({ + authToken, + url: `v2/nebula/usage?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch AI usage, ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as AIUsageStats[]; + }, + ["getAiUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); - const json = await res.json(); - return json.data as InAppWalletStats[]; +export function getAiUsage(params: AnalyticsQueryParams, authToken: string) { + return cached_getAiUsage(normalizedParams(params), authToken); } -export async function getUserOpUsage( +const cached_getUserOpUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + const res = await fetchAnalytics({ + authToken, + url: `v2/bundler/usage?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch user ops usage: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as UserOpStats[]; + }, + ["getUserOpUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getUserOpUsage( params: AnalyticsQueryParams, -): Promise { - const searchParams = buildSearchParams(params); - const res = await fetchAnalytics(`v1/user-ops?${searchParams.toString()}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); + authToken: string, +) { + return cached_getUserOpUsage(normalizedParams(params), authToken); +} - if (res?.status !== 200) { - const reason = await res?.text(); - console.error( - `Failed to fetch user ops usage: ${res?.status} - ${res.statusText} - ${reason}`, +const cached_getAggregateUserOpUsage = unstable_cache( + async ( + params: Omit, + authToken: string, + ): Promise => { + const [userOpStats, chains] = await Promise.all([ + getUserOpUsage({ ...params, period: "all" }, authToken), + getChains(), + ]); + + // Aggregate stats across wallet types + const aggregated = userOpStats.reduce( + ( + acc: UserOpStats & { gasPriceSum: number; gasPriceCount: number }, + curr, + ) => { + // Skip testnets from the aggregated stats + if (curr.chainId) { + const chain = chains.data.find( + (c) => c.chainId.toString() === curr.chainId, + ); + if (chain?.testnet) { + return acc; + } + } + + acc.successful += curr.successful; + acc.failed += curr.failed; + acc.sponsoredUsd += curr.sponsoredUsd; + acc.gasUnits += curr.gasUnits; + // For avgGasPrice, we'll track sum and count for proper averaging + acc.gasPriceSum += curr.avgGasPrice * curr.successful; + acc.gasPriceCount += curr.successful; + return acc; + }, + { + date: (params.from || new Date()).toISOString(), + failed: 0, + sponsoredUsd: 0, + successful: 0, + gasUnits: 0, + avgGasPrice: 0, + gasPriceSum: 0, + gasPriceCount: 0, + }, ); - return []; - } - const json = await res.json(); - return json.data as UserOpStats[]; + // Calculate the proper average gas price + aggregated.avgGasPrice = + aggregated.gasPriceCount > 0 + ? aggregated.gasPriceSum / aggregated.gasPriceCount + : 0; + + // Return only the UserOpStats fields + return { + date: aggregated.date, + failed: aggregated.failed, + sponsoredUsd: aggregated.sponsoredUsd, + successful: aggregated.successful, + gasUnits: aggregated.gasUnits, + avgGasPrice: aggregated.avgGasPrice, + }; + }, + ["getAggregateUserOpUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getAggregateUserOpUsage( + params: Omit, + authToken: string, +) { + return cached_getAggregateUserOpUsage(normalizedParams(params), authToken); } -export async function getClientTransactions( +const cached_getClientTransactions = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + + const res = await fetchAnalytics({ + authToken, + url: `v2/sdk/contract-transactions?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch client transactions stats: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as TransactionStats[]; + }, + ["getClientTransactions"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getClientTransactions( params: AnalyticsQueryParams, -): Promise { - const searchParams = buildSearchParams(params); - const res = await fetchAnalytics( - `v1/transactions/client?${searchParams.toString()}`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - }, - ); + authToken: string, +) { + return cached_getClientTransactions(normalizedParams(params), authToken); +} - if (res?.status !== 200) { - const reason = await res?.text(); - console.error( - `Failed to fetch client transactions stats: ${res?.status} - ${res.statusText} - ${reason}`, - ); - return []; - } +const cached_getRpcMethodUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); - const json = await res.json(); - return json.data as TransactionStats[]; + const res = await fetchAnalytics({ + authToken, + url: `v2/rpc/evm-methods?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + console.error("Failed to fetch RPC method usage"); + return []; + } + + const json = await res.json(); + return json.data as RpcMethodStats[]; + }, + ["getRpcMethodUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getRpcMethodUsage( + params: AnalyticsQueryParams, + authToken: string, +) { + return cached_getRpcMethodUsage(normalizedParams(params), authToken); } -export async function getRpcMethodUsage( +const cached_getRpcUsageByType = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + + const res = await fetchAnalytics({ + authToken, + url: `v2/rpc/usage-types?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + console.error("Failed to fetch RPC usage"); + return []; + } + + const json = await res.json(); + return json.data as RpcUsageTypeStats[]; + }, + ["getRpcUsageByType"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getRpcUsageByType( params: AnalyticsQueryParams, -): Promise { - const searchParams = buildSearchParams(params); - const res = await fetchAnalytics( - `v1/rpc/evm-methods?${searchParams.toString()}`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - }, + authToken: string, +) { + return cached_getRpcUsageByType(normalizedParams(params), authToken); +} + +type ActiveStatus = { + bundler: boolean; + storage: boolean; + rpc: boolean; + nebula: boolean; + sdk: boolean; + insight: boolean; + pay: boolean; + inAppWallet: boolean; + ecosystemWallet: boolean; + engineCloud: boolean; +}; + +export const isProjectActive = unstable_cache( + async (params: { + teamId: string; + projectId: string; + authToken: string; + }): Promise => { + const searchParams = buildSearchParams(params); + const res = await fetchAnalytics({ + authToken: params.authToken, + url: `v2/active-usage?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch project active status: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return { + bundler: false, + ecosystemWallet: false, + inAppWallet: false, + insight: false, + nebula: false, + pay: false, + rpc: false, + sdk: false, + engineCloud: false, + storage: false, + } as ActiveStatus; + } + + const json = await res.json(); + return json.data as ActiveStatus; + }, + ["isProjectActive"], + { + revalidate: 30, // 30 seconds + }, +); + +type EcosystemWalletUsageParams = { + teamId: string; + ecosystemSlug: string; + ecosystemPartnerId?: string; + projectId?: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}; + +const cached_getEcosystemWalletUsage = unstable_cache( + async (args: EcosystemWalletUsageParams, authToken: string) => { + const { + ecosystemSlug, + ecosystemPartnerId, + teamId, + projectId, + from, + to, + period, + } = args; + + const searchParams = new URLSearchParams(); + // required params + searchParams.append("ecosystemSlug", ecosystemSlug); + searchParams.append("teamId", teamId); + + // optional params + if (ecosystemPartnerId) { + searchParams.append("ecosystemPartnerId", ecosystemPartnerId); + } + if (projectId) { + searchParams.append("projectId", projectId); + } + if (from) { + searchParams.append("from", from.toISOString()); + } + if (to) { + searchParams.append("to", to.toISOString()); + } + if (period) { + searchParams.append("period", period); + } + + const res = await fetchAnalytics({ + authToken: authToken, + url: `v2/wallet/connects?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch ecosystem wallet stats: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return null; + } + + const json = await res.json(); + + return json.data as EcosystemWalletStats[]; + }, + ["getEcosystemWalletUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getEcosystemWalletUsage( + params: EcosystemWalletUsageParams, + authToken: string, +) { + return cached_getEcosystemWalletUsage(normalizedParams(params), authToken); +} + +type UniversalBridgeUsageParams = { + teamId: string; + projectId?: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}; + +const cached_getUniversalBridgeUsage = unstable_cache( + async (args: UniversalBridgeUsageParams, authToken: string) => { + const searchParams = buildSearchParams(args); + + const res = await fetchAnalytics({ + authToken, + url: `v2/universal?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch bridge stats: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as UniversalBridgeStats[]; + }, + ["getUniversalBridgeUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getUniversalBridgeUsage( + params: UniversalBridgeUsageParams, + authToken: string, +) { + return cached_getUniversalBridgeUsage(normalizedParams(params), authToken); +} + +type UniversalBridgeWalletUsageParams = { + teamId: string; + projectId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}; + +const cached_getUniversalBridgeWalletUsage = unstable_cache( + async (args: UniversalBridgeWalletUsageParams, authToken: string) => { + const searchParams = buildSearchParams(args); + + const res = await fetchAnalytics({ + authToken, + url: `v2/universal/wallets?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as UniversalBridgeWalletStats[]; + }, + ["getUniversalBridgeWalletUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getUniversalBridgeWalletUsage( + params: UniversalBridgeWalletUsageParams, + authToken: string, +) { + return cached_getUniversalBridgeWalletUsage( + normalizedParams(params), + authToken, ); +} - if (res?.status !== 200) { - console.error("Failed to fetch RPC method usage"); - return []; - } +const _cached_getWebhookSummary = unstable_cache( + async ( + params: AnalyticsQueryParams & { webhookId: string }, + authToken: string, + ): Promise<{ data: WebhookSummaryStats[] } | { error: string }> => { + const searchParams = buildSearchParams(params); + searchParams.append("webhookId", params.webhookId); + + const res = await fetchAnalytics({ + authToken, + url: `v2/webhook/summary?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + if (!res.ok) { + const reason = await res.text(); + return { error: reason }; + } + + return (await res.json()) as { data: WebhookSummaryStats[] }; + }, + ["getWebhookSummary"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +const _cached_getWebhookLatency = unstable_cache( + async ( + params: AnalyticsQueryParams & { webhookId?: string }, + authToken: string, + ): Promise<{ data: WebhookLatencyStats[] } | { error: string }> => { + const searchParams = buildSearchParams(params); + if (params.webhookId) { + searchParams.append("webhookId", params.webhookId); + } + + const res = await fetchAnalytics({ + authToken, + url: `v2/webhook/latency?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + if (!res.ok) { + const reason = await res.text(); + return { error: reason }; + } + + return (await res.json()) as { data: WebhookLatencyStats[] }; + }, + ["getWebhookLatency"], + { + revalidate: 60 * 60, // 1 hour + }, +); - const json = await res.json(); - return json.data as RpcMethodStats[]; +const cached_getInsightChainUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> => { + const searchParams = buildSearchParams(params); + + const res = await fetchAnalytics({ + authToken, + url: `v2/insight/usage/by-chain?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + const errMsg = `Failed to fetch Insight chain usage: ${res?.status} - ${res.statusText} - ${reason}`; + console.error(errMsg); + return { errorMessage: errMsg }; + } + + const json = await res.json(); + return { data: json.data as InsightChainStats[] }; + }, + ["getInsightChainUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getInsightChainUsage( + params: AnalyticsQueryParams, + authToken: string, +) { + return cached_getInsightChainUsage(normalizedParams(params), authToken); } -export async function getWalletUsers( +const cached_getInsightStatusCodeUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise<{ data: InsightStatusCodeStats[] } | { errorMessage: string }> => { + const searchParams = buildSearchParams(params); + + const res = await fetchAnalytics({ + authToken, + url: `v2/insight/usage/by-status-code?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + const errMsg = `Failed to fetch Insight status code usage: ${res?.status} - ${res.statusText} - ${reason}`; + console.error(errMsg); + return { errorMessage: errMsg }; + } + + const json = await res.json(); + return { data: json.data as InsightStatusCodeStats[] }; + }, + ["getInsightStatusCodeUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getInsightStatusCodeUsage( params: AnalyticsQueryParams, -): Promise { - const searchParams = buildSearchParams(params); - const res = await fetchAnalytics( - `v1/wallets/users?${searchParams.toString()}`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - }, - ); + authToken: string, +) { + return cached_getInsightStatusCodeUsage(normalizedParams(params), authToken); +} - if (res?.status !== 200) { - console.error("Failed to fetch wallet user stats"); - return []; - } +const cached_getInsightEndpointUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise<{ data: InsightEndpointStats[] } | { errorMessage: string }> => { + const searchParams = buildSearchParams(params); + + const res = await fetchAnalytics({ + authToken, + url: `v2/insight/usage/by-endpoint?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); - const json = await res.json(); - return json.data as WalletUserStats[]; + if (res?.status !== 200) { + const reason = await res?.text(); + const errMsg = `Failed to fetch Insight endpoint usage: ${res?.status} - ${res.statusText} - ${reason}`; + console.error(errMsg); + return { errorMessage: errMsg }; + } + + const json = await res.json(); + return { data: json.data as InsightEndpointStats[] }; + }, + ["getInsightEndpointUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getInsightEndpointUsage( + params: AnalyticsQueryParams, + authToken: string, +) { + return cached_getInsightEndpointUsage(normalizedParams(params), authToken); } -export async function isProjectActive( +const cached_getInsightUsage = unstable_cache( + async ( + params: AnalyticsQueryParams, + authToken: string, + ): Promise<{ data: InsightUsageStats[] } | { errorMessage: string }> => { + const searchParams = buildSearchParams(params); + + const res = await fetchAnalytics({ + authToken, + url: `v2/insight/usage?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + const errMsg = `Failed to fetch Insight usage: ${res?.status} - ${res.statusText} - ${reason}`; + console.error(errMsg); + return { errorMessage: errMsg }; + } + + const json = await res.json(); + return { data: json.data as InsightUsageStats[] }; + }, + ["getInsightUsage"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getInsightUsage( params: AnalyticsQueryParams, -): Promise { - const searchParams = buildSearchParams(params); - const res = await fetchAnalytics(`v1/active?${searchParams.toString()}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); + authToken: string, +) { + return cached_getInsightUsage(normalizedParams(params), authToken); +} - if (res?.status !== 200) { - console.error("Failed to fetch project active status"); - return false; - } +const cached_getX402Settlements = unstable_cache( + async ( + params: X402QueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + + if (params.groupBy) { + searchParams.append("groupBy", params.groupBy); + } + + const res = await fetchAnalytics({ + authToken, + url: `v2/x402/settlements?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch x402 settlements: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as X402SettlementStats[]; + }, + ["getX402Settlements"], + { + revalidate: 60 * 60, // 1 hour + }, +); - const json = await res.json(); - return json.data.isActive as boolean; +export function getX402Settlements(params: X402QueryParams, authToken: string) { + return cached_getX402Settlements(normalizedParams(params), authToken); } diff --git a/apps/dashboard/src/@/api/auth-token.ts b/apps/dashboard/src/@/api/auth-token.ts new file mode 100644 index 00000000000..cbbe2cf3e45 --- /dev/null +++ b/apps/dashboard/src/@/api/auth-token.ts @@ -0,0 +1,54 @@ +import { cookies } from "next/headers"; +import { + COOKIE_ACTIVE_ACCOUNT, + COOKIE_PREFIX_TOKEN, + LAST_USED_TEAM_ID, +} from "@/constants/cookie"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; + +export async function getAuthToken() { + const cookiesManager = await cookies(); + const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value; + const token = activeAccount + ? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value + : null; + + return token; +} + +export async function getAuthTokenWalletAddress() { + const cookiesManager = await cookies(); + const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value; + if (!activeAccount) { + return null; + } + + const token = cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value; + + if (token) { + return activeAccount; + } + + return null; +} + +export async function getUserThirdwebClient(params: { + teamId: string | undefined; +}) { + const authToken = await getAuthToken(); + + if (params.teamId) { + return getClientThirdwebClient({ + jwt: authToken, + teamId: params.teamId, + }); + } + + const cookiesManager = await cookies(); + const lastUsedTeamId = cookiesManager.get(LAST_USED_TEAM_ID)?.value; + + return getClientThirdwebClient({ + jwt: authToken, + teamId: lastUsedTeamId, + }); +} diff --git a/apps/dashboard/src/@/api/chain.ts b/apps/dashboard/src/@/api/chain.ts new file mode 100644 index 00000000000..b03b854d7c3 --- /dev/null +++ b/apps/dashboard/src/@/api/chain.ts @@ -0,0 +1,22 @@ +import "server-only"; +import type { ChainMetadata } from "thirdweb/chains"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import type { ChainService } from "@/types/chain"; + +export function getChains() { + return fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains`, + // revalidate every 60 minutes + { next: { revalidate: 60 * 60 } }, + ).then((res) => res.json()) as Promise<{ data: ChainMetadata[] }>; +} + +export function getChainServices() { + return fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains/services`, + // revalidate every 60 minutes + { next: { revalidate: 60 * 60 } }, + ).then((res) => res.json()) as Promise<{ + data: Record>; + }>; +} diff --git a/apps/dashboard/src/components/contract-components/fetch-contracts-with-versions.ts b/apps/dashboard/src/@/api/contract/fetch-contracts-with-versions.ts similarity index 94% rename from apps/dashboard/src/components/contract-components/fetch-contracts-with-versions.ts rename to apps/dashboard/src/@/api/contract/fetch-contracts-with-versions.ts index b0797f01da5..ea7b85e66f7 100644 --- a/apps/dashboard/src/components/contract-components/fetch-contracts-with-versions.ts +++ b/apps/dashboard/src/@/api/contract/fetch-contracts-with-versions.ts @@ -1,5 +1,4 @@ -import { getThirdwebClient } from "@/constants/thirdweb.server"; -import { type ThirdwebClient, isAddress } from "thirdweb"; +import { isAddress, type ThirdwebClient } from "thirdweb"; import { fetchDeployMetadata } from "thirdweb/contract"; import { resolveAddress } from "thirdweb/extensions/ens"; import { @@ -18,12 +17,12 @@ export function mapThirdwebPublisher(publisher: string) { export async function fetchPublishedContractVersions( publisherAddress: string, contractId: string, + client: ThirdwebClient, ) { - const client = getThirdwebClient(); const sortedVersions = await getSortedPublishedContractVersions({ - publisherAddress, - contractId, client, + contractId, + publisherAddress, }); const responses = await Promise.allSettled( @@ -58,13 +57,13 @@ async function getSortedPublishedContractVersions(params: { const allVersions = await getPublishedContractVersions({ contract: getContractPublisher(client), + contractId: contractId, publisher: isAddress(publisherAddress) ? publisherAddress : await resolveAddress({ client, name: mapThirdwebPublisher(publisherAddress), }), - contractId: contractId, }); const sortedVersions = allVersions.toSorted((a, b) => { @@ -80,13 +79,12 @@ async function getSortedPublishedContractVersions(params: { export async function fetchLatestPublishedContractVersion( publisherAddress: string, contractId: string, + client: ThirdwebClient, ) { - const client = getThirdwebClient(); - const sortedVersions = await getSortedPublishedContractVersions({ - publisherAddress, - contractId, client, + contractId, + publisherAddress, }); const latestVersion = sortedVersions[0]; @@ -104,12 +102,14 @@ export async function fetchLatestPublishedContractVersion( export async function fetchPublishedContractVersion( publisherAddress: string, contractId: string, + client: ThirdwebClient, version = "latest", ) { if (version === "latest") { const latestVersion = await fetchLatestPublishedContractVersion( publisherAddress, contractId, + client, ); return latestVersion || null; @@ -118,6 +118,7 @@ export async function fetchPublishedContractVersion( const allVersions = await fetchPublishedContractVersions( publisherAddress, contractId, + client, ); if (allVersions.length === 0) { diff --git a/apps/dashboard/src/@/api/contract/fetchDeployMetadata.ts b/apps/dashboard/src/@/api/contract/fetchDeployMetadata.ts new file mode 100644 index 00000000000..3d6ada9000c --- /dev/null +++ b/apps/dashboard/src/@/api/contract/fetchDeployMetadata.ts @@ -0,0 +1,27 @@ +import type { ThirdwebClient } from "thirdweb"; +import { fetchDeployMetadata as sdkFetchDeployMetadata } from "thirdweb/contract"; +import { removeUndefinedFromObjectDeep } from "@/utils/object"; +import type { ContractId } from "../../components/contract-components/types"; + +// metadata PRE publish, only has the compiler output info (from CLI) + +export async function fetchDeployMetadata( + contractId: string, + client: ThirdwebClient, +) { + const contractIdIpfsHash = toContractIdIpfsHash(contractId); + + return removeUndefinedFromObjectDeep( + await sdkFetchDeployMetadata({ + client, + uri: contractIdIpfsHash, + }), + ); +} + +function toContractIdIpfsHash(contractId: ContractId) { + if (contractId?.startsWith("ipfs://")) { + return contractId; + } + return `ipfs://${contractId}`; +} diff --git a/apps/dashboard/src/@/api/contract/fetchPublishedContracts.ts b/apps/dashboard/src/@/api/contract/fetchPublishedContracts.ts new file mode 100644 index 00000000000..72049375043 --- /dev/null +++ b/apps/dashboard/src/@/api/contract/fetchPublishedContracts.ts @@ -0,0 +1,44 @@ +import type { ThirdwebClient } from "thirdweb"; +import { + getAllPublishedContracts, + getContractPublisher, +} from "thirdweb/extensions/thirdweb"; +import { resolveEns } from "@/lib/ens"; +import { fetchDeployMetadata } from "./fetchDeployMetadata"; + +// TODO: clean this up, jesus +export async function fetchPublishedContracts(params: { + address?: string | null; + client: ThirdwebClient; +}) { + const { address, client } = params; + try { + if (!address) { + return []; + } + const resolvedAddress = (await resolveEns(address, client)).address; + + if (!resolvedAddress) { + return []; + } + + const tempResult = ( + (await getAllPublishedContracts({ + contract: getContractPublisher(client), + publisher: resolvedAddress, + })) || [] + ) + .filter((c) => c.contractId) + .sort((a, b) => a.contractId.localeCompare(b.contractId)); + + return await Promise.all( + tempResult.map(async (c) => ({ + ...c, + metadata: await fetchDeployMetadata(c.publishMetadataUri, client), + })), + ); + } catch (e) { + console.error("Error fetching published contracts", e); + return []; + } +} diff --git a/apps/dashboard/src/@/api/contract/fetchPublishedContractsFromDeploy.ts b/apps/dashboard/src/@/api/contract/fetchPublishedContractsFromDeploy.ts new file mode 100644 index 00000000000..562380b17f6 --- /dev/null +++ b/apps/dashboard/src/@/api/contract/fetchPublishedContractsFromDeploy.ts @@ -0,0 +1,64 @@ +import type { ThirdwebContract } from "thirdweb"; +import { fetchPublishedContract } from "thirdweb/contract"; +import { + getContractPublisher, + getPublishedUriFromCompilerUri, +} from "thirdweb/extensions/thirdweb"; +import { download } from "thirdweb/storage"; +import { + extractIPFSUri, + isZkSyncChain, + resolveImplementation, +} from "thirdweb/utils"; +import { THIRDWEB_DEPLOYER_ADDRESS } from "@/constants/addresses"; +import { fetchDeployMetadata } from "./fetchDeployMetadata"; + +type ZkSolcMetadata = { + source_metadata: { settings: { compilationTarget: Record } }; +}; + +export async function fetchPublishedContractsFromDeploy(options: { + contract: ThirdwebContract; +}) { + const { contract } = options; + const { bytecode } = await resolveImplementation(contract); + const contractUri = extractIPFSUri(bytecode); + if (!contractUri) { + throw new Error("No IPFS URI found in bytecode"); + } + + let publishURIs = await getPublishedUriFromCompilerUri({ + compilerMetadataUri: contractUri, + contract: getContractPublisher(contract.client), + }); + + // Try fetching using contract name from compiler metadata for zksolc variants + // TODO: ContractPublisher should handle multiple metadata uri for a published version + if (publishURIs.length === 0 && (await isZkSyncChain(contract.chain))) { + try { + const res = await download({ + client: contract.client, + uri: contractUri, + }); + + const deployMetadata = (await res.json()) as ZkSolcMetadata; + + const contractId = Object.values( + deployMetadata.source_metadata.settings.compilationTarget, + ); + + if (contractId[0]) { + const published = await fetchPublishedContract({ + client: contract.client, + contractId: contractId[0], + publisherAddress: THIRDWEB_DEPLOYER_ADDRESS, + }); + publishURIs = [published.publishMetadataUri]; + } + } catch {} + } + + return await Promise.all( + publishURIs.map((uri) => fetchDeployMetadata(uri, contract.client)), + ); +} diff --git a/apps/dashboard/src/components/contract-components/getContractFunctionsFromAbi.ts b/apps/dashboard/src/@/api/contract/getContractFunctionsFromAbi.ts similarity index 100% rename from apps/dashboard/src/components/contract-components/getContractFunctionsFromAbi.ts rename to apps/dashboard/src/@/api/contract/getContractFunctionsFromAbi.ts diff --git a/apps/dashboard/src/@/api/contract/verify-contract.ts b/apps/dashboard/src/@/api/contract/verify-contract.ts new file mode 100644 index 00000000000..4dd7d1c2224 --- /dev/null +++ b/apps/dashboard/src/@/api/contract/verify-contract.ts @@ -0,0 +1,27 @@ +import type { ThirdwebContract } from "thirdweb"; + +export async function verifyContract(contract: ThirdwebContract) { + try { + const response = await fetch( + "https://contract.thirdweb.com/verify/contract", + { + body: JSON.stringify({ + chainId: contract.chain.id, + contractAddress: contract.address, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + console.error(`Error verifying contract: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error verifying contract: ${error}`); + } +} diff --git a/apps/dashboard/src/@/api/insight/webhooks.ts b/apps/dashboard/src/@/api/insight/webhooks.ts new file mode 100644 index 00000000000..43eb91180d8 --- /dev/null +++ b/apps/dashboard/src/@/api/insight/webhooks.ts @@ -0,0 +1,240 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +"use server"; + +import { getAuthToken } from "@/api/auth-token"; +import { THIRDWEB_INSIGHT_API_DOMAIN } from "@/constants/urls"; + +export interface WebhookResponse { + id: string; + name: string; + team_id: string; + project_id: string; + webhook_url: string; + webhook_secret: string; + filters: WebhookFilters; + suspended_at: string | null; + suspended_reason: string | null; + disabled: boolean; + created_at: string; + updated_at: string | null; +} + +export interface WebhookFilters { + "v1.events"?: { + chain_ids?: string[]; + addresses?: string[]; + signatures?: Array<{ + sig_hash: string; + abi?: string; + params?: Record; + }>; + }; + "v1.transactions"?: { + chain_ids?: string[]; + from_addresses?: string[]; + to_addresses?: string[]; + signatures?: Array<{ + sig_hash: string; + abi?: string; + params?: Record; + }>; + }; +} + +interface CreateWebhookPayload { + name: string; + webhook_url: string; + filters: WebhookFilters; +} + +interface WebhooksListResponse { + data: WebhookResponse[]; + error?: string; +} + +interface WebhookSingleResponse { + data: WebhookResponse | null; + error?: string; +} + +interface TestWebhookPayload { + webhook_id?: string; + webhook_url: string; + type?: "event" | "transaction"; +} + +interface TestWebhookResponse { + success: boolean; + error?: string; +} + +type SupportedWebhookChainsResponse = + | { chains: Array } + | { error: string }; + +export async function createWebhook( + payload: CreateWebhookPayload, + clientId: string, +): Promise { + try { + const authToken = await getAuthToken(); + const response = await fetch( + `https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks`, + { + body: JSON.stringify(payload), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": clientId, + }, + method: "POST", + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + data: null, + error: `Failed to create webhook: ${errorText}`, + }; + } + + return (await response.json()) as WebhookSingleResponse; + } catch (error) { + return { + data: null, + error: `Network or parsing error: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } +} + +export async function getWebhooks( + clientId: string, +): Promise { + try { + const authToken = await getAuthToken(); + const response = await fetch( + `https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "x-client-id": clientId, + }, + method: "GET", + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + data: [], + error: `Failed to get webhooks: ${errorText}`, + }; + } + + return (await response.json()) as WebhooksListResponse; + } catch (error) { + return { + data: [], + error: `Network or parsing error: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } +} + +export async function deleteWebhook( + webhookId: string, + clientId: string, +): Promise { + try { + const authToken = await getAuthToken(); + const response = await fetch( + `https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks/${encodeURIComponent(webhookId)}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "x-client-id": clientId, + }, + method: "DELETE", + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + data: null, + error: `Failed to delete webhook: ${errorText}`, + }; + } + + return (await response.json()) as WebhookSingleResponse; + } catch (error) { + return { + data: null, + error: `Network or parsing error: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } +} + +export async function testWebhook( + payload: TestWebhookPayload, + clientId: string, +): Promise { + try { + const authToken = await getAuthToken(); + const response = await fetch( + `https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks/test`, + { + body: JSON.stringify(payload), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": clientId, + }, + method: "POST", + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + error: `Failed to test webhook: ${errorText}`, + success: false, + }; + } + + return (await response.json()) as TestWebhookResponse; + } catch (error) { + return { + error: `Network or parsing error: ${error instanceof Error ? error.message : "Unknown error"}`, + success: false, + }; + } +} + +export async function getSupportedWebhookChains(): Promise { + try { + const response = await fetch( + `https://${THIRDWEB_INSIGHT_API_DOMAIN}/service/chains`, + { + headers: { + "x-service-api-key": process.env.INSIGHT_SERVICE_API_KEY || "", + }, + method: "GET", + }, + ); + + if (!response.ok) { + const errorText = await response.json(); + return { error: `Failed to fetch supported chains: ${errorText.error}` }; + } + + const data = await response.json(); + if (Array.isArray(data.data)) { + return { chains: data.data }; + } + return { error: "Unexpected response format" }; + } catch (error) { + console.error("Error fetching supported chains:", error); + return { error: `Failed to fetch supported chains: ${error}` }; + } +} diff --git a/apps/dashboard/src/@/api/notifications.ts b/apps/dashboard/src/@/api/notifications.ts new file mode 100644 index 00000000000..f4db133dd09 --- /dev/null +++ b/apps/dashboard/src/@/api/notifications.ts @@ -0,0 +1,157 @@ +"use server"; + +import "server-only"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import { getAuthToken } from "./auth-token"; + +export type Notification = { + id: string; + createdAt: string; + accountId: string; + teamId: string | null; + description: string; + readAt: string | null; + ctaText: string; + ctaUrl: string; +}; + +export type NotificationsApiResponse = { + result: Notification[]; + nextCursor?: string; +}; + +export async function getUnreadNotifications(cursor?: string) { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + const url = new URL( + "/v1/dashboard-notifications/unread", + NEXT_PUBLIC_THIRDWEB_API_HOST, + ); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + } as const; + } + + const data = (await response.json()) as NotificationsApiResponse; + + return { + data, + status: "success", + } as const; +} + +export async function getArchivedNotifications(cursor?: string) { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + + const url = new URL( + "/v1/dashboard-notifications/archived", + NEXT_PUBLIC_THIRDWEB_API_HOST, + ); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + } as const; + } + + const data = (await response.json()) as NotificationsApiResponse; + + return { + data, + status: "success", + } as const; +} + +export async function getUnreadNotificationsCount() { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + + const url = new URL( + "/v1/dashboard-notifications/unread-count", + NEXT_PUBLIC_THIRDWEB_API_HOST, + ); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + } as const; + } + const data = (await response.json()) as { + result: { + unreadCount: number; + }; + }; + return { + data, + status: "success", + } as const; +} + +export async function markNotificationAsRead(notificationId?: string) { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + const url = new URL( + "/v1/dashboard-notifications/mark-as-read", + NEXT_PUBLIC_THIRDWEB_API_HOST, + ); + const response = await fetch(url, { + // if notificationId is provided, mark it as read, otherwise mark all as read + body: JSON.stringify(notificationId ? { notificationId } : {}), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "PUT", + }); + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + } as const; + } + return { + status: "success", + } as const; +} diff --git a/apps/dashboard/src/@/api/project/cache-tag.ts b/apps/dashboard/src/@/api/project/cache-tag.ts new file mode 100644 index 00000000000..e786089d1ea --- /dev/null +++ b/apps/dashboard/src/@/api/project/cache-tag.ts @@ -0,0 +1,6 @@ +export function projectContractsCacheTag(params: { + teamId: string; + projectId: string; +}) { + return `${params.teamId}/${params.projectId}/project-contracts`; +} diff --git a/apps/dashboard/src/@/api/project/getProjectContracts.ts b/apps/dashboard/src/@/api/project/getProjectContracts.ts new file mode 100644 index 00000000000..396bce5dbae --- /dev/null +++ b/apps/dashboard/src/@/api/project/getProjectContracts.ts @@ -0,0 +1,86 @@ +import "server-only"; +import { getAddress } from "thirdweb"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export type ProjectContract = { + id: string; + contractAddress: string; + chainId: string; + createdAt: string; + updatedAt: string; + deploymentType: string | null; + contractType: string | null; +}; + +export async function getProjectContracts(options: { + teamId: string; + projectId: string; + authToken: string; + deploymentType: string | undefined; +}) { + const url = new URL( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/projects/${options.projectId}/contracts`, + ); + if (options.deploymentType) { + url.searchParams.set("deploymentType", options.deploymentType); + } + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${options.authToken}`, + }, + }); + + if (!res.ok) { + const errorMessage = await res.text(); + console.error("Error fetching project contracts"); + console.error(errorMessage); + return []; + } + + const data = (await res.json()) as { + result: ProjectContract[]; + }; + + return data.result; +} + +export type PartialProject = { + id: string; + name: string; + slug: string; + image: string | null; +}; + +/** + * get a list of projects within a team that have a given contract imported + */ +export async function getContractImportedProjects(options: { + teamId: string; + authToken: string; + chainId: number; + contractAddress: string; +}) { + const url = new URL( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/projects/contracts/lookup?chainId=${options.chainId}&contractAddress=${getAddress(options.contractAddress)}`, + ); + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${options.authToken}`, + }, + }); + + if (!res.ok) { + const errorMessage = await res.text(); + console.error("Error fetching: /projects/contracts/lookup"); + console.error(errorMessage); + return []; + } + + const data = (await res.json()) as { + result: PartialProject[]; + }; + + return data.result; +} diff --git a/apps/dashboard/src/@/api/project/getSortedDeployedContracts.tsx b/apps/dashboard/src/@/api/project/getSortedDeployedContracts.tsx new file mode 100644 index 00000000000..816de1854ae --- /dev/null +++ b/apps/dashboard/src/@/api/project/getSortedDeployedContracts.tsx @@ -0,0 +1,111 @@ +import { unstable_cache } from "next/cache"; +import pLimit from "p-limit"; +import { defineChain } from "thirdweb"; +import { getContract } from "thirdweb/contract"; +import { + getProjectContracts, + type ProjectContract, +} from "@/api/project/getProjectContracts"; +import { supportedERCs } from "@/utils/supportedERCs"; +import { serverThirdwebClient } from "../../constants/thirdweb-client.server"; +import { resolveFunctionSelectors } from "../../lib/selectors"; +import { projectContractsCacheTag } from "./cache-tag"; + +type GetFilteredProjectContractsParams = { + teamId: string; + projectId: string; + authToken: string; +}; + +type ProjectContractWithIsToken = ProjectContract & { isToken: boolean }; + +async function getProjectContractsWithIsTokenInfo( + params: GetFilteredProjectContractsParams, +): Promise { + const contracts = await getProjectContracts({ + authToken: params.authToken, + deploymentType: undefined, // don't pass it to api to fetch all contracts, we'll filter/merge manually here + projectId: params.projectId, + teamId: params.teamId, + }); + + const assetContracts = contracts.filter((c) => c.deploymentType === "asset"); + const otherContracts = contracts.filter((c) => c.deploymentType !== "asset"); + + // "asset" deployment type contract is assumed to be a "token" type contract already + // for others, check if its a "token" type contract (isERC20, isERC721 or isERC1155) + + const limit = pLimit(20); + + const othersContractWithIsTokenInfo: ProjectContractWithIsToken[] = + await Promise.all( + otherContracts.map(async (c) => { + return limit(async () => { + try { + const contract = getContract({ + address: c.contractAddress, + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(Number(c.chainId)), + client: serverThirdwebClient, + }); + + const functionSelectors = await resolveFunctionSelectors(contract); + const ercs = supportedERCs(functionSelectors); + if (ercs.isERC20 || ercs.isERC721 || ercs.isERC1155) { + return { + ...c, + isToken: true, + }; + } + return { + ...c, + isToken: false, + }; + } catch { + return { + ...c, + isToken: false, + }; + } + }); + }), + ); + + for (const c of othersContractWithIsTokenInfo) { + if (c.deploymentType === "asset") { + assetContracts.push(c); + } else { + otherContracts.push(c); + } + } + + return [ + ...assetContracts.map( + (c) => ({ ...c, isToken: true }) as ProjectContractWithIsToken, + ), + ...othersContractWithIsTokenInfo, + ]; +} + +export async function getFilteredProjectContracts( + params: GetFilteredProjectContractsParams & { + type: "token-contracts" | "non-token-contracts"; + }, +) { + const cached_getProjectContractsWithIsTokenInfo = unstable_cache( + getProjectContractsWithIsTokenInfo, + ["get-project-contracts-with-is-token-info"], + { + revalidate: 3600, // 1 hour + tags: [projectContractsCacheTag(params)], + }, + ); + + const contracts = await cached_getProjectContractsWithIsTokenInfo(params); + + if (params.type === "token-contracts") { + return contracts.filter((c) => c.isToken); + } else { + return contracts.filter((c) => !c.isToken); + } +} diff --git a/apps/dashboard/src/@/api/project/projects.ts b/apps/dashboard/src/@/api/project/projects.ts new file mode 100644 index 00000000000..126ff06939e --- /dev/null +++ b/apps/dashboard/src/@/api/project/projects.ts @@ -0,0 +1,48 @@ +import "server-only"; +import type { ProjectResponse } from "@thirdweb-dev/service-utils"; +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export type Project = ProjectResponse; + +export async function getProjects(teamSlug: string) { + const token = await getAuthToken(); + + if (!token) { + return []; + } + + const teamsRes = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamSlug}/projects`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (teamsRes.ok) { + return (await teamsRes.json())?.result as Project[]; + } + return []; +} + +export async function getProject(teamSlug: string, projectSlug: string) { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + const teamsRes = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamSlug}/projects/${projectSlug}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (teamsRes.ok) { + return (await teamsRes.json())?.result as Project; + } + return null; +} diff --git a/apps/dashboard/src/@/api/projects.ts b/apps/dashboard/src/@/api/projects.ts deleted file mode 100644 index f45ecf02496..00000000000 --- a/apps/dashboard/src/@/api/projects.ts +++ /dev/null @@ -1,62 +0,0 @@ -import "server-only"; -import { API_SERVER_URL } from "@/constants/env"; -import { getAuthToken } from "../../app/api/lib/getAuthToken"; - -export type Project = { - id: string; - name: string; - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; - bannedAt: Date | null; - domains: string[]; - bundleIds: string[]; - redirectUrls: string[]; - lastAccessedAt: Date | null; - slug: string; - teamId: string; - publishableKey: string; - // image: string; // TODO -}; - -export async function getProjects(teamSlug: string) { - const token = await getAuthToken(); - - if (!token) { - return []; - } - - const teamsRes = await fetch( - `${API_SERVER_URL}/v1/teams/${teamSlug}/projects`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - if (teamsRes.ok) { - return (await teamsRes.json())?.result as Project[]; - } - return []; -} - -export async function getProject(teamSlug: string, projectSlug: string) { - const token = await getAuthToken(); - - if (!token) { - return null; - } - - const teamsRes = await fetch( - `${API_SERVER_URL}/v1/teams/${teamSlug}/projects/${projectSlug}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - if (teamsRes.ok) { - return (await teamsRes.json())?.result as Project; - } - return null; -} diff --git a/apps/dashboard/src/@/api/team-members.ts b/apps/dashboard/src/@/api/team-members.ts deleted file mode 100644 index bc754f39fca..00000000000 --- a/apps/dashboard/src/@/api/team-members.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "server-only"; -import { API_SERVER_URL } from "@/constants/env"; -import { getAuthToken } from "../../app/api/lib/getAuthToken"; - -const TeamAccountRole = { - OWNER: "OWNER", - MEMBER: "MEMBER", -} as const; - -export type TeamAccountRole = - (typeof TeamAccountRole)[keyof typeof TeamAccountRole]; - -export type TeamMember = { - account: { - name: string; - email: string | null; - }; -} & { - deletedAt: Date | null; - accountId: string; - teamId: string; - createdAt: Date; - updatedAt: Date; - role: TeamAccountRole; -}; - -export async function getMembers(teamSlug: string) { - const token = await getAuthToken(); - - if (!token) { - return undefined; - } - - const teamsRes = await fetch( - `${API_SERVER_URL}/v1/teams/${teamSlug}/members`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (teamsRes.ok) { - return (await teamsRes.json())?.result as TeamMember[]; - } - - return undefined; -} diff --git a/apps/dashboard/src/@/api/team-subscription.ts b/apps/dashboard/src/@/api/team-subscription.ts deleted file mode 100644 index b5d91133a86..00000000000 --- a/apps/dashboard/src/@/api/team-subscription.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { getAuthToken } from "../../app/api/lib/getAuthToken"; -import { API_SERVER_URL } from "../constants/env"; -import type { ProductSKU } from "../lib/billing"; - -type InvoiceLine = { - // amount for this line item - amount: number; - // statement descriptor - description: string | null; - // the thirdweb product sku or null if it is not recognized - thirdwebSku: ProductSKU | null; -}; - -type Invoice = { - // total amount excluding tax - amount: number | null; - // the ISO currency code (e.g. USD) - currency: string; - // the line items on the invoice - lines: InvoiceLine[]; -}; - -export type TeamSubscription = { - id: string; - type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT"; - status: - | "incomplete" - | "incomplete_expired" - | "trialing" - | "active" - | "past_due" - | "canceled" - | "unpaid" - | "paused"; - currentPeriodStart: string; - currentPeriodEnd: string; - trialStart: string | null; - trialEnd: string | null; - upcomingInvoice: Invoice; -}; - -export async function getTeamSubscriptions(slug: string) { - const token = await getAuthToken(); - - if (!token) { - return null; - } - - const teamRes = await fetch( - `${API_SERVER_URL}/v1/teams/${slug}/subscriptions`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (teamRes.ok) { - return (await teamRes.json())?.result as TeamSubscription[]; - } - return null; -} diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts deleted file mode 100644 index 78ea7550851..00000000000 --- a/apps/dashboard/src/@/api/team.ts +++ /dev/null @@ -1,93 +0,0 @@ -import "server-only"; -import { API_SERVER_URL } from "@/constants/env"; -import { getAuthToken } from "../../app/api/lib/getAuthToken"; - -type EnabledTeamScope = - | "pay" - | "storage" - | "rpc" - | "bundler" - | "insight" - | "embeddedWallets" - | "relayer" - | "chainsaw" - | "nebula"; - -export type Team = { - id: string; - name: string; - slug: string; - createdAt: string; - updatedAt: string; - deletedAt?: string; - bannedAt?: string; - image?: string; - billingPlan: "pro" | "growth" | "free" | "starter"; - billingStatus: "validPayment" | (string & {}) | null; - billingEmail: string | null; - growthTrialEligible: false; - enabledScopes: EnabledTeamScope[]; -}; - -export async function getTeamBySlug(slug: string) { - const token = await getAuthToken(); - - if (!token) { - return null; - } - - const teamRes = await fetch(`${API_SERVER_URL}/v1/teams/${slug}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (teamRes.ok) { - return (await teamRes.json())?.result as Team; - } - return null; -} - -export async function getTeams() { - const token = await getAuthToken(); - if (!token) { - return null; - } - - const teamsRes = await fetch(`${API_SERVER_URL}/v1/teams`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (teamsRes.ok) { - return (await teamsRes.json())?.result as Team[]; - } - return null; -} - -type TeamNebulaWaitList = { - onWaitlist: boolean; - createdAt: null | string; -}; - -export async function getTeamNebulaWaitList(teamSlug: string) { - const token = await getAuthToken(); - - if (!token) { - return null; - } - - const res = await fetch( - `${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist?scope=nebula`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (res.ok) { - return (await res.json()).result as TeamNebulaWaitList; - } - - return null; -} diff --git a/apps/dashboard/src/@/api/team/audit-log.ts b/apps/dashboard/src/@/api/team/audit-log.ts new file mode 100644 index 00000000000..3067f52e489 --- /dev/null +++ b/apps/dashboard/src/@/api/team/audit-log.ts @@ -0,0 +1,91 @@ +"use server"; + +import "server-only"; +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export type AuditLogEntry = { + who: { + text: string; + metadata?: { + email?: string; + image?: string; + wallet?: string; + clientId?: string; + }; + type: "user" | "apikey" | "system"; + path?: string; + }; + what: { + text: string; + action: "create" | "update" | "delete"; + path?: string; + in?: { + text: string; + path?: string; + }; + description?: string; + resourceType: + | "team" + | "project" + | "team-member" + | "team-invite" + | "contract" + | "secret-key"; + }; + when: string; +}; + +type AuditLogApiResponse = { + result: AuditLogEntry[]; + nextCursor?: string; + hasMore: boolean; +}; + +export async function getAuditLogs(teamSlug: string, cursor?: string) { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + const url = new URL( + `/v1/teams/${teamSlug}/audit-log`, + NEXT_PUBLIC_THIRDWEB_API_HOST, + ); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + // artifically limit page size to 15 for now + url.searchParams.set("take", "15"); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + next: { + // revalidate this query once per 10 seconds (does not need to be more granular than that) + revalidate: 10, + }, + }); + if (!response.ok) { + // if the status is 402, the most likely reason is that the team is on a free plan + if (response.status === 402) { + return { + reason: "higher_plan_required", + status: "error", + } as const; + } + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + } as const; + } + + const data = (await response.json()) as AuditLogApiResponse; + + return { + data, + status: "success", + } as const; +} diff --git a/apps/dashboard/src/@/api/team/dedicated-support.ts b/apps/dashboard/src/@/api/team/dedicated-support.ts new file mode 100644 index 00000000000..620e98b4c11 --- /dev/null +++ b/apps/dashboard/src/@/api/team/dedicated-support.ts @@ -0,0 +1,40 @@ +"use server"; +import "server-only"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export async function createDedicatedSupportChannel( + teamIdOrSlug: string, + channelType: "slack" | "telegram", +): Promise<{ error: string | null }> { + const token = await getAuthToken(); + if (!token) { + return { error: "Unauthorized" }; + } + + const res = await fetch( + new URL( + `/v1/teams/${teamIdOrSlug}/dedicated-support-channel`, + NEXT_PUBLIC_THIRDWEB_API_HOST, + ), + { + body: JSON.stringify({ + type: channelType, + }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + if (!res.ok) { + const json = await res.json(); + return { + error: + json.error?.message ?? "Failed to create dedicated support channel.", + }; + } + return { error: null }; +} diff --git a/apps/dashboard/src/@/api/team/ecosystems.ts b/apps/dashboard/src/@/api/team/ecosystems.ts new file mode 100644 index 00000000000..acb1adeff95 --- /dev/null +++ b/apps/dashboard/src/@/api/team/ecosystems.ts @@ -0,0 +1,167 @@ +import "server-only"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export type AuthOption = + | "email" + | "phone" + | "passkey" + | "siwe" + | "guest" + | "google" + | "facebook" + | "x" + | "tiktok" + | "discord" + | "farcaster" + | "telegram" + | "github" + | "twitch" + | "steam" + | "apple" + | "coinbase" + | "line" + | "epic"; + +export type Ecosystem = { + name: string; + imageUrl?: string; + id: string; + slug: string; + permission: "PARTNER_WHITELIST" | "ANYONE"; + authOptions: AuthOption[]; + customAuthOptions?: { + authEndpoint?: { + url: string; + headers?: { key: string; value: string }[]; + }; + jwt?: { + jwksUri: string; + aud: string; + }; + } | null; + smartAccountOptions?: { + defaultChainId: number; + sponsorGas: boolean; + accountFactoryAddress?: string; + executionMode?: "EIP4337" | "EIP7702"; + } | null; + url: string; + status: "active" | "requested" | "paymentFailed"; + createdAt: string; + updatedAt: string; +}; + +export async function fetchEcosystemList(teamIdOrSlug: string) { + const token = await getAuthToken(); + + if (!token) { + return []; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!res.ok) { + return []; + } + + return (await res.json()).result as Ecosystem[]; +} + +export async function fetchEcosystem(slug: string, teamIdOrSlug: string) { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (!res.ok) { + const data = await res.json(); + console.error(data); + return null; + } + + const data = (await res.json()) as { result: Ecosystem }; + return data.result; +} + +type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1"; +export type Partner = { + id: string; + name: string; + imageUrl?: string; + allowlistedDomains: string[]; + allowlistedBundleIds: string[]; + 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[]; + }; diff --git a/apps/dashboard/src/@/api/team/get-team.ts b/apps/dashboard/src/@/api/team/get-team.ts new file mode 100644 index 00000000000..a5cf46ca7bc --- /dev/null +++ b/apps/dashboard/src/@/api/team/get-team.ts @@ -0,0 +1,130 @@ +import "server-only"; +import type { TeamResponse } from "@thirdweb-dev/service-utils"; +import { cookies } from "next/headers"; +import { getValidAccount } from "@/api/account/get-account"; +import { getAuthToken } from "@/api/auth-token"; +import { LAST_USED_TEAM_ID } from "@/constants/cookie"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import { API_SERVER_SECRET } from "@/constants/server-envs"; +import { getMemberByAccountId } from "./team-members"; + +export type Team = TeamResponse & { + stripeCustomerId: string | null; + isLegacyPlan: boolean; +}; + +export async function getTeamBySlug(slug: string) { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + const teamRes = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${slug}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (teamRes.ok) { + return (await teamRes.json())?.result as Team; + } + return null; +} + +export async function service_getTeamBySlug(slug: string) { + const teamRes = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${slug}`, + { + headers: { + "x-service-api-key": API_SERVER_SECRET, + }, + }, + ); + + if (teamRes.ok) { + return (await teamRes.json())?.result as Team; + } + + return null; +} + +function getTeamById(id: string) { + return getTeamBySlug(id); +} + +export async function getTeams() { + const token = await getAuthToken(); + if (!token) { + return null; + } + + const teamsRes = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (teamsRes.ok) { + return (await teamsRes.json())?.result as Team[]; + } + return null; +} + +/** @deprecated */ +export async function getDefaultTeam() { + const token = await getAuthToken(); + if (!token) { + return null; + } + + const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/~`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (res.ok) { + return (await res.json())?.result as Team; + } + return null; +} + +export async function getLastVisitedTeam() { + const token = await getAuthToken(); + if (!token) { + return null; + } + + const cookiesStore = await cookies(); + const lastVisitedTeamId = cookiesStore.get(LAST_USED_TEAM_ID)?.value; + + if (lastVisitedTeamId) { + const team = await getTeamById(lastVisitedTeamId); + if (team) { + return team; + } + } + + return null; +} + +export async function hasToCompleteTeamOnboarding( + team: Team, + pagePath: string, +) { + // if the team is already onboarded, we don't need to check anything else here + if (team.isOnboarded) { + return false; + } + const account = await getValidAccount(pagePath); + const teamMember = await getMemberByAccountId(team.slug, account.id); + + // if the team member is not an owner (or we cannot find them), they cannot complete onboarding anyways + if (teamMember?.role !== "OWNER") { + return false; + } + + // if we get here the team is not onboarded and the team member is an owner, so we need to show the onboarding page + return true; +} diff --git a/apps/dashboard/src/@/api/team/team-invites.ts b/apps/dashboard/src/@/api/team/team-invites.ts new file mode 100644 index 00000000000..cecb6fbbde5 --- /dev/null +++ b/apps/dashboard/src/@/api/team/team-invites.ts @@ -0,0 +1,46 @@ +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export type TeamInvite = { + id: string; + teamId: string; + email: string; + role: "OWNER" | "MEMBER"; + createdAt: string; + status: "pending" | "accepted" | "expired"; + expiresAt: string; +}; + +export async function getTeamInvites( + teamId: string, + options: { + count: number; + start: number; + }, +) { + const authToken = await getAuthToken(); + + if (!authToken) { + throw new Error("Unauthorized"); + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamId}/invites?skip=${options.start}&take=${options.count}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + ); + + if (!res.ok) { + const errorMessage = await res.text(); + throw new Error(errorMessage); + } + + const json = (await res.json()) as { + result: TeamInvite[]; + }; + + return json.result; +} diff --git a/apps/dashboard/src/@/api/team/team-members.ts b/apps/dashboard/src/@/api/team/team-members.ts new file mode 100644 index 00000000000..a3ea96e3b0f --- /dev/null +++ b/apps/dashboard/src/@/api/team/team-members.ts @@ -0,0 +1,78 @@ +import "server-only"; +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +const TeamAccountRole = { + MEMBER: "MEMBER", + OWNER: "OWNER", +} as const; + +export type TeamAccountRole = + (typeof TeamAccountRole)[keyof typeof TeamAccountRole]; + +export type TeamMember = { + account: { + creatorWalletAddress: string; + name: string; + email: string | null; + image: string | null; + }; + deletedAt: string | null; + accountId: string; + teamId: string; + createdAt: string; + updatedAt: string; + role: TeamAccountRole; +}; + +export async function getMembers(teamSlug: string) { + const token = await getAuthToken(); + + if (!token) { + return undefined; + } + + const teamsRes = await fetch( + new URL(`/v1/teams/${teamSlug}/members`, NEXT_PUBLIC_THIRDWEB_API_HOST), + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (teamsRes.ok) { + return (await teamsRes.json())?.result as TeamMember[]; + } + + return undefined; +} + +export async function getMemberByAccountId( + teamSlug: string, + accountId: string, +) { + const token = await getAuthToken(); + + if (!token) { + return undefined; + } + + const res = await fetch( + new URL( + `/v1/teams/${teamSlug}/members/${accountId}`, + NEXT_PUBLIC_THIRDWEB_API_HOST, + ), + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (res.ok) { + return (await res.json())?.result as TeamMember; + } + + return undefined; +} diff --git a/apps/dashboard/src/@/api/team/team-subscription.ts b/apps/dashboard/src/@/api/team/team-subscription.ts new file mode 100644 index 00000000000..c7097ce2f22 --- /dev/null +++ b/apps/dashboard/src/@/api/team/team-subscription.ts @@ -0,0 +1,127 @@ +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import type { ChainInfraSKU, ProductSKU } from "@/types/billing"; + +type InvoiceLine = { + // amount for this line item + amount: number; + // statement descriptor + description: string | null; + // the thirdweb product sku or null if it is not recognized + thirdwebSku: ProductSKU | null; +}; + +type Invoice = { + // total amount excluding tax + amount: number | null; + // the ISO currency code (e.g. USD) + currency: string; + // the line items on the invoice + lines: InvoiceLine[]; +}; + +export type TeamSubscription = { + id: string; + type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT" | "CHAIN"; + status: + | "incomplete" + | "incomplete_expired" + | "trialing" + | "active" + | "past_due" + | "canceled" + | "unpaid" + | "paused"; + currentPeriodStart: string; + currentPeriodEnd: string; + trialStart: string | null; + trialEnd: string | null; + upcomingInvoice: Invoice; + skus: (ProductSKU | ChainInfraSKU)[]; +}; + +type ChainTeamSubscription = Omit & { + chainId: string; + skus: ChainInfraSKU[]; + isLegacy: boolean; +}; + +export async function getTeamSubscriptions(slug: string) { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + const teamRes = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${slug}/subscriptions`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (teamRes.ok) { + return (await teamRes.json())?.result as TeamSubscription[]; + } + return null; +} + +const CHAIN_PLAN_TO_INFRA = { + "chain:plan:gold": ["chain:infra:rpc", "chain:infra:account_abstraction"], + "chain:plan:platinum": [ + "chain:infra:rpc", + "chain:infra:insight", + "chain:infra:account_abstraction", + ], + "chain:plan:ultimate": [ + "chain:infra:rpc", + "chain:infra:insight", + "chain:infra:account_abstraction", + ], +}; + +async function getChainSubscriptions(slug: string) { + const allSubscriptions = await getTeamSubscriptions(slug); + if (!allSubscriptions) { + return null; + } + + // first replace any sku that MIGHT match a chain plan + const updatedSubscriptions = allSubscriptions + .filter((s) => s.type === "CHAIN") + .map((s) => { + const skus = s.skus; + const updatedSkus = skus.flatMap((sku) => { + const plan = + CHAIN_PLAN_TO_INFRA[sku as keyof typeof CHAIN_PLAN_TO_INFRA]; + return plan ? plan : sku; + }); + return { + ...s, + isLegacy: updatedSkus.length !== skus.length, + skus: updatedSkus, + }; + }); + + return updatedSubscriptions.filter( + (s): s is ChainTeamSubscription => + "chainId" in s && typeof s.chainId === "string", + ); +} + +export async function getChainSubscriptionForChain( + slug: string, + chainId: number, +) { + const chainSubscriptions = await getChainSubscriptions(slug); + + if (!chainSubscriptions) { + return null; + } + + return ( + chainSubscriptions.find((s) => s.chainId === chainId.toString()) ?? null + ); +} diff --git a/apps/dashboard/src/@/api/team/verified-domain.ts b/apps/dashboard/src/@/api/team/verified-domain.ts new file mode 100644 index 00000000000..241f2dde71b --- /dev/null +++ b/apps/dashboard/src/@/api/team/verified-domain.ts @@ -0,0 +1,94 @@ +"use server"; +import "server-only"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export type VerifiedDomainResponse = + | { + status: "pending"; + domain: string; + dnsSublabel: string; + dnsValue: string; + } + | { + status: "verified"; + domain: string; + verifiedAt: Date; + }; + +export async function checkDomainVerification( + teamIdOrSlug: string, +): Promise { + const token = await getAuthToken(); + + if (!token) { + return null; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/verified-domain`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (res.ok) { + return (await res.json())?.result as VerifiedDomainResponse; + } + + return null; +} + +export async function createDomainVerification( + teamIdOrSlug: string, + domain: string, +): Promise { + const token = await getAuthToken(); + + if (!token) { + return { + error: "Unauthorized", + }; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/verified-domain`, + { + body: JSON.stringify({ domain }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (res.ok) { + return (await res.json())?.result as VerifiedDomainResponse; + } + + const resJson = (await res.json()) as { + error: { + code: string; + message: string; + statusCode: number; + }; + }; + + switch (resJson?.error?.statusCode) { + case 400: + return { + error: "The domain you provided is not valid.", + }; + case 409: + return { + error: "This domain is already verified by another team.", + }; + default: + return { + error: resJson?.error?.message ?? "Failed to verify domain", + }; + } +} diff --git a/apps/dashboard/src/@/api/universal-bridge/constants.ts b/apps/dashboard/src/@/api/universal-bridge/constants.ts new file mode 100644 index 00000000000..7f2d826be2c --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/constants.ts @@ -0,0 +1 @@ +export const UB_BASE_URL = process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST; diff --git a/apps/dashboard/src/@/api/universal-bridge/developer.ts b/apps/dashboard/src/@/api/universal-bridge/developer.ts new file mode 100644 index 00000000000..77a6261d074 --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts @@ -0,0 +1,560 @@ +import type { Address } from "thirdweb"; +import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs"; + +const UB_BASE_URL = NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST; + +export type Webhook = { + url: string; + label: string; + active: boolean; + createdAt: string; + id: string; + version?: number; // TODO (UB) make this mandatory after migration +}; + +export async function getWebhooks(params: { + clientId: string; + teamId: string; + authToken: string; +}) { + const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "GET", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Array; +} + +export async function getWebhookById(params: { + clientId: string; + teamId: string; + authToken: string; + webhookId: string; +}) { + const res = await fetch( + `${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`, + { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "GET", + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Webhook; +} + +export async function createWebhook(params: { + clientId: string; + teamId: string; + version?: number; + url: string; + label: string; + secret?: string; + authToken: string; +}) { + const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, { + body: JSON.stringify({ + label: params.label, + secret: params.secret, + url: params.url, + version: params.version, + }), + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "POST", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + return (await res.json()) as Webhook; +} + +export async function deleteWebhook(params: { + clientId: string; + teamId: string; + webhookId: string; + authToken: string; +}) { + const res = await fetch( + `${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`, + { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "DELETE", + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + return true; +} + +export async function updateWebhook(params: { + clientId: string; + teamId: string; + webhookId: string; + authToken: string; + body: { + version?: number; + url: string; + label: string; + }; +}) { + const res = await fetch( + `${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`, + { + method: "PUT", + body: JSON.stringify(params.body), + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + + return json.data as Webhook; +} + +export type WebhookSend = { + id: string; + webhookId: string; + webhookUrl: string; + createdAt: string; + onrampId: string | null; + paymentId: string | null; + response: string | null; + responseStatus: number; + status: "PENDING" | "COMPLETED" | "FAILED"; + success: boolean; + transactionId: string | null; + body: unknown; +}; + +type WebhookSendsResponse = { + data: WebhookSend[]; + pagination: { + limit: number; + offset: number; + total: number; + }; +}; + +export async function getWebhookSends(options: { + authToken: string; + projectClientId: string; + teamId: string; + limit?: number; + offset?: number; + webhookId: string; + success?: boolean; +}): Promise { + const { limit, offset, success, webhookId, authToken } = options; + + const url = new URL( + `${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhook-sends`, + ); + + url.searchParams.set("webhookId", webhookId); + + if (limit !== undefined) { + url.searchParams.set("limit", limit.toString()); + } + if (offset !== undefined) { + url.searchParams.set("offset", offset.toString()); + } + if (success !== undefined) { + url.searchParams.set("success", success.toString()); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "x-client-id": options.projectClientId, + "x-team-id": options.teamId, + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(errorJson.message); + } + + return (await response.json()) as WebhookSendsResponse; +} + +export async function resendWebhook( + params: { + authToken: string; + projectClientId: string; + teamId: string; + } & ( + | { + paymentId: string; + } + | { + onrampId: string; + } + ), +) { + const url = new URL( + `${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhooks/retry`, + ); + + const response = await fetch(url.toString(), { + method: "POST", + body: JSON.stringify( + "paymentId" in params + ? { paymentId: params.paymentId } + : { onrampId: params.onrampId }, + ), + headers: { + "Content-Type": "application/json", + "x-client-id": params.projectClientId, + "x-team-id": params.teamId, + Authorization: `Bearer ${params.authToken}`, + }, + }); + + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(errorJson.message); + } + + return true; +} + +type PaymentLink = { + id: string; + link: string; + title: string; + imageUrl: string; + createdAt: string; + updatedAt: string; + destinationToken: { + chainId: number; + address: Address; + symbol: string; + name: string; + decimals: number; + iconUri: string; + }; + receiver: Address; + amount: bigint; +}; + +export async function getPaymentLinks(params: { + clientId: string; + teamId: string; + authToken: string; +}): Promise> { + const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "GET", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = (await res.json()) as { + data: Array; + }; + return json.data.map((link) => ({ + id: link.id, + link: link.link, + title: link.title, + imageUrl: link.imageUrl, + createdAt: link.createdAt, + updatedAt: link.updatedAt, + destinationToken: { + chainId: link.destinationToken.chainId, + address: link.destinationToken.address, + symbol: link.destinationToken.symbol, + name: link.destinationToken.name, + decimals: link.destinationToken.decimals, + iconUri: link.destinationToken.iconUri, + }, + receiver: link.receiver, + amount: BigInt(link.amount), + })); +} + +export async function createPaymentLink(params: { + clientId: string; + teamId: string; + title: string; + imageUrl?: string; + intent: { + destinationChainId: number; + destinationTokenAddress: Address; + receiver: Address; + amount: bigint; + purchaseData?: unknown; + }; + authToken: string; +}) { + const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, { + body: JSON.stringify({ + title: params.title, + imageUrl: params.imageUrl, + intent: { + destinationChainId: params.intent.destinationChainId, + destinationTokenAddress: params.intent.destinationTokenAddress, + receiver: params.intent.receiver, + amount: params.intent.amount.toString(), + purchaseData: params.intent.purchaseData, + }, + }), + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "POST", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const response = (await res.json()) as { + data: PaymentLink; + }; + + return response.data; +} + +export async function deletePaymentLink(params: { + clientId: string; + teamId: string; + paymentLinkId: string; + authToken: string; +}) { + const res = await fetch( + `${UB_BASE_URL}/v1/developer/links/${encodeURIComponent(params.paymentLinkId)}`, + { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "DELETE", + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + return true; +} + +export type Fee = { + feeRecipient: string; + feeBps: number; + createdAt: string; + updatedAt: string; +}; + +export async function getFees(params: { + clientId: string; + teamId: string; + authToken: string; +}) { + const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "GET", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Fee; +} + +export async function updateFee(params: { + clientId: string; + teamId: string; + feeRecipient: string; + feeBps: number; + authToken: string; +}) { + const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, { + body: JSON.stringify({ + feeBps: params.feeBps, + feeRecipient: params.feeRecipient, + }), + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "PUT", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + return true; +} + +export type PaymentsResponse = { + data: Payment[]; + meta: { + totalCount: number; + }; +}; + +type BridgePaymentType = "buy" | "sell" | "transfer"; +type OnrampPaymentType = "onramp"; + +export type Payment = { + // common + id: string; + createdAt: string; + clientId: string; + receiver: string; + transactions: Array<{ + chainId: number; + transactionHash: string; + }>; + status: "PENDING" | "COMPLETED" | "FAILED" | "NOT_FOUND"; + + destinationAmount: string; + destinationToken: { + address: string; + symbol: string; + decimals: number; + chainId: number; + }; + purchaseData: unknown; +} & ( + | { + type: BridgePaymentType; + transactionId: string; + blockNumber?: string; + sender: string; + developerFeeRecipient: string; + developerFeeBps: number; + originAmount: string; + originToken: { + address: string; + symbol: string; + decimals: number; + chainId: number; + }; + } + | { + onrampId: string; + type: OnrampPaymentType; + } +); + +export type BridgePayment = Extract; + +export async function getPayments(params: { + clientId: string; + teamId: string; + paymentLinkId?: string; + limit?: number; + authToken: string; + offset?: number; +}) { + const url = new URL(`${UB_BASE_URL}/v1/developer/payments`); + + if (params.limit) { + url.searchParams.append("limit", params.limit.toString()); + } + + if (params.offset) { + url.searchParams.append("offset", params.offset.toString()); + } + + if (params.paymentLinkId) { + url.searchParams.append("paymentLinkId", params.paymentLinkId); + } + + const res = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "GET", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json as PaymentsResponse; +} diff --git a/apps/dashboard/src/@/api/universal-bridge/links.ts b/apps/dashboard/src/@/api/universal-bridge/links.ts new file mode 100644 index 00000000000..d8c3e152c88 --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/links.ts @@ -0,0 +1,45 @@ +import "server-only"; + +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { UB_BASE_URL } from "./constants"; + +type PaymentLink = { + clientId: string; + title?: string; + imageUrl?: string; + receiver: string; + destinationToken: { + address: string; + symbol: string; + decimals: number; + chainId: number; + }; + amount: bigint | undefined; + purchaseData: Record | undefined; +}; + +export async function getPaymentLink(props: { paymentId: string }) { + const res = await fetch(`${UB_BASE_URL}/v1/links/${props.paymentId}`, { + headers: { + "Content-Type": "application/json", + "x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY, + }, + method: "GET", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const { data } = await res.json(); + return { + amount: data.amount ? BigInt(data.amount) : undefined, + clientId: data.clientId, + destinationToken: data.destinationToken, + imageUrl: data.imageUrl, + purchaseData: data.purchaseData, + receiver: data.receiver, + title: data.title, + } as PaymentLink; +} diff --git a/apps/dashboard/src/@/api/universal-bridge/token-list.ts b/apps/dashboard/src/@/api/universal-bridge/token-list.ts new file mode 100644 index 00000000000..8cf14a2cf90 --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/token-list.ts @@ -0,0 +1,35 @@ +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; +import { UB_BASE_URL } from "./constants"; +import type { TokenMetadata } from "./types"; + +export async function getUniversalBridgeTokens(props: { + chainId?: number; + address?: string; +}) { + const url = new URL(`${UB_BASE_URL}/v1/tokens`); + + if (props.chainId) { + url.searchParams.append("chainId", String(props.chainId)); + } + if (props.address) { + url.searchParams.append("tokenAddress", props.address); + } + url.searchParams.append("limit", "1000"); + url.searchParams.append("includePrices", "false"); + + const res = await fetch(url.toString(), { + headers: { + "Content-Type": "application/json", + "x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID, + }, + method: "GET", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Array; +} diff --git a/apps/dashboard/src/@/api/universal-bridge/tokens.ts b/apps/dashboard/src/@/api/universal-bridge/tokens.ts new file mode 100644 index 00000000000..2e1863f97f8 --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/tokens.ts @@ -0,0 +1,35 @@ +"use server"; +import type { ProjectResponse } from "@thirdweb-dev/service-utils"; +import { getAuthToken } from "@/api/auth-token"; +import { UB_BASE_URL } from "./constants"; +import type { TokenMetadata } from "./types"; + +export async function addUniversalBridgeTokenRoute(props: { + chainId: number; + tokenAddress: string; + project: ProjectResponse; +}) { + const authToken = await getAuthToken(); + const url = new URL(`${UB_BASE_URL}/v1/tokens`); + + const res = await fetch(url.toString(), { + body: JSON.stringify({ + chainId: props.chainId, + tokenAddress: props.tokenAddress, + }), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": props.project.publishableKey, + }, + method: "POST", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Array; +} diff --git a/apps/dashboard/src/@/api/universal-bridge/types.ts b/apps/dashboard/src/@/api/universal-bridge/types.ts new file mode 100644 index 00000000000..608f81ce14d --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/types.ts @@ -0,0 +1,8 @@ +export type TokenMetadata = { + name: string; + symbol: string; + address: string; + decimals: number; + chainId: number; + iconUri?: string; +}; diff --git a/apps/dashboard/src/@/api/usage/billing-preview.ts b/apps/dashboard/src/@/api/usage/billing-preview.ts new file mode 100644 index 00000000000..755d10d0542 --- /dev/null +++ b/apps/dashboard/src/@/api/usage/billing-preview.ts @@ -0,0 +1,66 @@ +import "server-only"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +type LineItem = { + quantity: number; + amountUsdCents: number; + unitAmountUsdCents: string; + description: string; +}; + +export type UsageCategory = { + category: string; + unitName: string; + lineItems: LineItem[]; +}; + +type UsageApiResponse = { + result: UsageCategory[]; + periodStart: string; + periodEnd: string; + planVersion: number; +}; + +export async function getBilledUsage(teamSlug: string) { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + const response = await fetch( + new URL( + `/v1/teams/${teamSlug}/billing/billed-usage`, + NEXT_PUBLIC_THIRDWEB_API_HOST, + ), + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + next: { + // revalidate this query once per minute (does not need to be more granular than that) + revalidate: 60, + }, + }, + ); + if (!response.ok) { + // if the status is 404, the most likely reason is that the team is on a free plan + if (response.status === 404) { + return { + reason: "free_plan", + status: "error", + } as const; + } + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + } as const; + } + const data = (await response.json()) as UsageApiResponse; + return { + data, + status: "success", + } as const; +} diff --git a/apps/dashboard/src/@/api/usage/rpc.ts b/apps/dashboard/src/@/api/usage/rpc.ts new file mode 100644 index 00000000000..915ab5096c6 --- /dev/null +++ b/apps/dashboard/src/@/api/usage/rpc.ts @@ -0,0 +1,59 @@ +import "server-only"; +import { unstable_cache } from "next/cache"; +import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs"; + +type Last24HoursRPCUsageApiResponse = { + peakRate: { + date: string; + peakRPS: string; + }; + averageRate: { + date: string; + averageRate: string; + peakRPS: string; + includedCount: string; + rateLimitedCount: string; + overageCount: string; + }[]; + totalCounts: { + includedCount: string; + rateLimitedCount: string; + overageCount: string; + }; +}; + +export const getLast24HoursRPCUsage = unstable_cache( + async (params: { teamId: string; projectId?: string; authToken: string }) => { + const analyticsEndpoint = ANALYTICS_SERVICE_URL; + const url = new URL(`${analyticsEndpoint}/v2/rpc/24-hours`); + url.searchParams.set("teamId", params.teamId); + if (params.projectId) { + url.searchParams.set("projectId", params.projectId); + } + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${params.authToken}`, + }, + }); + + if (!res.ok) { + const error = await res.text(); + return { + error: error, + ok: false as const, + }; + } + + const resData = await res.json(); + + return { + data: resData.data as Last24HoursRPCUsageApiResponse, + ok: true as const, + }; + }, + ["rpc-usage-last-24-hours:v2"], + { + revalidate: 60, // 1 minute + }, +); diff --git a/apps/dashboard/src/@/api/x402/config.ts b/apps/dashboard/src/@/api/x402/config.ts new file mode 100644 index 00000000000..3b60fdf8207 --- /dev/null +++ b/apps/dashboard/src/@/api/x402/config.ts @@ -0,0 +1,27 @@ +import type { Project } from "@/api/project/projects"; + +type X402Fee = { + feeRecipient: string; + feeBps: number; +}; + +/** + * Extract x402 fee configuration from project's engineCloud service + */ +export function getX402Fees(project: Project): X402Fee { + const engineCloudService = project.services.find( + (service) => service.name === "engineCloud", + ); + + if (!engineCloudService) { + return { + feeRecipient: "", + feeBps: 0, + }; + } + + return { + feeRecipient: engineCloudService.x402FeeRecipient || "", + feeBps: engineCloudService.x402FeeBPS || 0, + }; +} diff --git a/apps/dashboard/src/@/components/ChakraProviderSetup.tsx b/apps/dashboard/src/@/components/ChakraProviderSetup.tsx deleted file mode 100644 index 43556ee2686..00000000000 --- a/apps/dashboard/src/@/components/ChakraProviderSetup.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { ChakraProvider, useColorMode } from "@chakra-ui/react"; -import { useTheme } from "next-themes"; -import { useEffect } from "react"; -import chakraTheme from "../../theme"; - -export function ChakraProviderSetup(props: { - children: React.ReactNode; -}) { - return ( - - {props.children} - - - ); -} - -function SyncTheme() { - const { theme, setTheme } = useTheme(); - const { setColorMode } = useColorMode(); - - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - setColorMode(theme === "light" ? "light" : "dark"); - }, [setColorMode, theme]); - - // handle dashboard with now old "system" set - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (theme === "system") { - setTheme("dark"); - setColorMode("dark"); - } - }, [theme, setTheme, setColorMode]); - - return null; -} diff --git a/apps/dashboard/src/@/components/TextDivider.tsx b/apps/dashboard/src/@/components/TextDivider.tsx deleted file mode 100644 index 7a0730f23fa..00000000000 --- a/apps/dashboard/src/@/components/TextDivider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { cn } from "@/lib/utils"; - -export function TextDivider(props: { - text: string; - className?: string; -}) { - return ( -
- - {props.text} - -
- ); -} diff --git a/apps/dashboard/src/@/components/analytics/date-range-selector.tsx b/apps/dashboard/src/@/components/analytics/date-range-selector.tsx new file mode 100644 index 00000000000..aa8fade78b6 --- /dev/null +++ b/apps/dashboard/src/@/components/analytics/date-range-selector.tsx @@ -0,0 +1,133 @@ +import { differenceInCalendarDays, format, subDays } from "date-fns"; +import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { normalizeTime } from "@/lib/time"; +import { cn } from "@/lib/utils"; + +export function DateRangeSelector(props: { + range: Range; + setRange: (range: Range) => void; + popoverAlign?: "start" | "end" | "center"; + className?: string; +}) { + const { range, setRange } = props; + const daysDiff = differenceInCalendarDays(range.to, range.from); + + const matchingRange = + normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime() + ? durationPresets.find((preset) => preset.days === daysDiff) + : undefined; + + const rangeType = matchingRange?.id || range.type; + const rangeLabel = matchingRange?.name || range.label; + + return ( + + +
+ } + labelOverride={rangeLabel} + popoverAlign={props.popoverAlign} + setFrom={(from) => + setRange({ + from, + to: range.to, + type: "custom", + }) + } + setTo={(to) => + setRange({ + from: range.from, + to, + type: "custom", + }) + } + to={range.to} + /> + ); +} + +export function getLastNDaysRange(id: DurationId) { + const durationInfo = durationPresets.find((preset) => preset.id === id); + if (!durationInfo) { + throw new Error(`Invalid duration id: ${id}`); + } + + const todayDate = new Date(); + todayDate.setDate(todayDate.getDate() + 1); // Move to next day + todayDate.setHours(0, 0, 0, 0); // Set to start of next day (00:00) + + const value: Range = { + from: subDays(todayDate, durationInfo.days), + label: durationInfo.name, + to: todayDate, + type: id, + }; + + return value; +} + +const durationPresets = [ + { + days: 7, + id: "last-7", + name: "Last 7 Days", + }, + { + days: 30, + id: "last-30", + name: "Last 30 Days", + }, + { + days: 60, + id: "last-60", + name: "Last 60 Days", + }, + { + days: 120, + id: "last-120", + name: "Last 120 Days", + }, +] as const; + +export type DurationId = (typeof durationPresets)[number]["id"]; + +export type Range = { + type: DurationId | "custom"; + label?: string; + from: Date; + to: Date; +}; diff --git a/apps/dashboard/src/@/components/analytics/empty-chart-state.tsx b/apps/dashboard/src/@/components/analytics/empty-chart-state.tsx new file mode 100644 index 00000000000..a1e3daf8670 --- /dev/null +++ b/apps/dashboard/src/@/components/analytics/empty-chart-state.tsx @@ -0,0 +1,76 @@ +"use client"; +import { ArrowUpRightIcon, XIcon } from "lucide-react"; +import Link from "next/link"; +import { LoadingDots } from "@/components/ui/LoadingDots"; +import { cn } from "@/lib/utils"; +import { Button } from "../ui/button"; + +export function EmptyChartState({ content }: { content?: React.ReactNode }) { + return ( +
+
+ {content || ( +
+
+ +
+

No data available

+
+ )} +
+
+ ); +} + +export function LoadingChartState({ className }: { className?: string }) { + return ( +
+ +
+ ); +} + +export function EmptyChartStateGetStartedCTA(props: { + link?: { + label: string; + href: string; + }; + title: string; + description?: string; +}) { + return ( +
+
+ +
+
+

+ {props.title} +

+ {props.description && ( +

+ {props.description} +

+ )} +
+ + {props.link && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/analytics/interval-selector.tsx b/apps/dashboard/src/@/components/analytics/interval-selector.tsx similarity index 100% rename from apps/dashboard/src/components/analytics/interval-selector.tsx rename to apps/dashboard/src/@/components/analytics/interval-selector.tsx index 003b0658320..99151768788 100644 --- a/apps/dashboard/src/components/analytics/interval-selector.tsx +++ b/apps/dashboard/src/@/components/analytics/interval-selector.tsx @@ -14,10 +14,10 @@ export function IntervalSelector(props: { }) { return ( setStringValue(e.target.value)} + className={cn( + "rounded-r-none border-none focus-visible:ring-0 focus-visible:ring-offset-0", + restInputProps.className, + )} + maxLength={5} onBlur={(e) => { const val = e.target.value; const validValue = val.match( @@ -59,7 +64,7 @@ export const BasisPointsInput: React.FC = ({ setStringValue("0.00"); } }} - maxLength={5} + onChange={(e) => setStringValue(e.target.value)} />
diff --git a/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx new file mode 100644 index 00000000000..cc3a563e92a --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx @@ -0,0 +1,261 @@ +/* eslint-disable no-restricted-syntax */ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useMemo, useRef } from "react"; +import { BridgeWidget, type SupportedFiatCurrency } from "thirdweb/react"; +import type { Wallet } from "thirdweb/wallets"; +import { + reportAssetBuyCancelled, + reportAssetBuyFailed, + reportAssetBuySuccessful, + reportSwapWidgetShown, + reportTokenBuyCancelled, + reportTokenBuyFailed, + reportTokenBuySuccessful, + reportTokenSwapCancelled, + reportTokenSwapFailed, + reportTokenSwapSuccessful, +} from "@/analytics/report"; +import { + NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID, + NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID, + NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID, + NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID, +} from "@/constants/public-envs"; +import { parseError } from "@/utils/errorParser"; +import { getSDKTheme } from "@/utils/sdk-component-theme"; +import { appMetadata } from "../../constants/connect"; +import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server"; + +type PageType = "asset" | "bridge" | "chain" | "bridge-iframe"; + +export type BuyAndSwapEmbedProps = { + persistTokenSelections?: boolean; + buyTab: + | { + buyToken: + | { + tokenAddress: string; + chainId: number; + amount?: string; + } + | undefined; + } + | undefined; + swapTab: + | { + sellToken: + | { + chainId: number; + tokenAddress: string; + amount?: string; + } + | undefined; + buyToken: + | { + chainId: number; + tokenAddress: string; + amount?: string; + } + | undefined; + } + | undefined; + pageType: PageType; + wallets?: Wallet[]; + currency?: SupportedFiatCurrency; + showThirdwebBranding?: boolean; +}; + +export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) { + const { theme } = useTheme(); + const themeObj = getSDKTheme(theme === "light" ? "light" : "dark"); + const isMounted = useRef(false); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (isMounted.current) { + return; + } + isMounted.current = true; + reportSwapWidgetShown({ + pageType: props.pageType, + }); + }, [props.pageType]); + + const client = useMemo(() => { + return getConfiguredThirdwebClient({ + clientId: + props.pageType === "asset" + ? NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID + : props.pageType === "bridge" + ? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID + : props.pageType === "chain" + ? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID + : props.pageType === "bridge-iframe" + ? NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID + : undefined, + secretKey: undefined, + teamId: undefined, + }); + }, [props.pageType]); + + return ( + { + const errorMessage = parseError(e); + + const buyChainId = + quote?.type === "buy" + ? quote.intent.destinationChainId + : quote?.type === "onramp" + ? quote.intent.chainId + : undefined; + + if (!buyChainId) { + return; + } + + reportTokenBuyFailed({ + buyTokenChainId: buyChainId, + buyTokenAddress: + quote?.type === "buy" + ? quote.intent.destinationTokenAddress + : quote?.type === "onramp" + ? quote.intent.tokenAddress + : undefined, + pageType: props.pageType, + }); + + if (props.pageType === "asset") { + reportAssetBuyFailed({ + assetType: "coin", + chainId: buyChainId, + error: errorMessage, + contractType: undefined, + is_testnet: false, + }); + } + }, + onCancel: (quote) => { + const buyChainId = + quote?.type === "buy" + ? quote.intent.destinationChainId + : quote?.type === "onramp" + ? quote.intent.chainId + : undefined; + + if (!buyChainId) { + return; + } + + reportTokenBuyCancelled({ + buyTokenChainId: buyChainId, + buyTokenAddress: + quote?.type === "buy" + ? quote.intent.destinationTokenAddress + : quote?.type === "onramp" + ? quote.intent.tokenAddress + : undefined, + pageType: props.pageType, + }); + + if (props.pageType === "asset") { + reportAssetBuyCancelled({ + assetType: "coin", + chainId: buyChainId, + contractType: undefined, + is_testnet: false, + }); + } + }, + onSuccess: ({ quote }) => { + const buyChainId = + quote?.type === "buy" + ? quote.intent.destinationChainId + : quote?.type === "onramp" + ? quote.intent.chainId + : undefined; + + if (!buyChainId) { + return; + } + + reportTokenBuySuccessful({ + buyTokenChainId: buyChainId, + buyTokenAddress: + quote?.type === "buy" + ? quote.intent.destinationTokenAddress + : quote?.type === "onramp" + ? quote.intent.tokenAddress + : undefined, + pageType: props.pageType, + }); + + if (props.pageType === "asset") { + reportAssetBuySuccessful({ + assetType: "coin", + chainId: buyChainId, + contractType: undefined, + is_testnet: false, + }); + } + }, + } + : undefined + } + swap={{ + persistTokenSelections: props.persistTokenSelections, + prefill: { + buyToken: props.swapTab?.buyToken, + sellToken: props.swapTab?.sellToken, + }, + onError: (error, quote) => { + const errorMessage = parseError(error); + reportTokenSwapFailed({ + errorMessage: errorMessage, + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, + }); + }, + onSuccess: ({ quote }) => { + reportTokenSwapSuccessful({ + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, + }); + }, + onCancel: (quote) => { + reportTokenSwapCancelled({ + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, + }); + }, + }} + currency={props.currency} + showThirdwebBranding={props.showThirdwebBranding} + /> + ); +} diff --git a/apps/dashboard/src/@/components/blocks/CurrencySelector.tsx b/apps/dashboard/src/@/components/blocks/CurrencySelector.tsx new file mode 100644 index 00000000000..eac5c1bdb91 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/CurrencySelector.tsx @@ -0,0 +1,200 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { isAddress, NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { CURRENCIES, type CurrencyMetadata } from "@/constants/currencies"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { cn } from "@/lib/utils"; + +interface CurrencySelectorProps { + value: string; + onChange?: (event: React.ChangeEvent) => void; + className?: string; + isDisabled?: boolean; + small?: boolean; + hideDefaultCurrencies?: boolean; + showCustomCurrency?: boolean; + isPaymentsSelector?: boolean; + defaultCurrencies?: CurrencyMetadata[]; + contractChainId: number; +} + +export function CurrencySelector({ + value, + onChange, + small, + hideDefaultCurrencies, + showCustomCurrency = true, + isPaymentsSelector = false, + defaultCurrencies = [], + contractChainId: chainId, + className, + isDisabled, +}: CurrencySelectorProps) { + const { idToChain } = useAllChainsData(); + const chain = chainId ? idToChain.get(chainId) : undefined; + + const helperCurrencies = + defaultCurrencies.length > 0 + ? defaultCurrencies + : chainId + ? CURRENCIES[chainId] || [] + : []; + + const [isAddingCurrency, setIsAddingCurrency] = useState(false); + const [editCustomCurrency, setEditCustomCurrency] = useState(""); + const [customCurrency, setCustomCurrency] = useState(""); + const [initialValue] = useState(value); + + const isCustomCurrency: boolean = useMemo(() => { + if (initialValue && chainId && initialValue !== customCurrency) { + if (chainId in CURRENCIES) { + return !CURRENCIES[chainId]?.find( + (currency: CurrencyMetadata) => + currency.address.toLowerCase() === initialValue.toLowerCase(), + ); + } + + // for non-default chains + return true; + } + + return false; + }, [chainId, customCurrency, initialValue]); + + const currencyOptions: CurrencyMetadata[] = [ + ...(isPaymentsSelector + ? [] + : [ + { + address: NATIVE_TOKEN_ADDRESS.toLowerCase(), + name: chain?.nativeCurrency.name || "Native Token", + symbol: chain?.nativeCurrency.symbol || "", + }, + ]), + ...(hideDefaultCurrencies ? [] : helperCurrencies), + ]; + + const addCustomCurrency = () => { + if (!isAddress(editCustomCurrency)) { + return; + } + if (editCustomCurrency) { + setCustomCurrency(editCustomCurrency); + if (onChange) { + onChange({ + target: { value: editCustomCurrency }, + // biome-ignore lint/suspicious/noExplicitAny: FIXME + } as any); + } + } else { + setEditCustomCurrency(customCurrency); + } + + setIsAddingCurrency(false); + setEditCustomCurrency(""); + }; + + if (isAddingCurrency && !hideDefaultCurrencies) { + return ( +
+ + setEditCustomCurrency(e.target.value)} + placeholder="ERC20 Address" + required + value={editCustomCurrency} + /> + +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx b/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx index 5db6a5eb906..cdd5f2bec43 100644 --- a/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx @@ -1,59 +1,52 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { BadgeContainer } from "@/storybook/utils"; import { DangerSettingCard } from "./DangerSettingCard"; const meta = { - title: "blocks/Cards/DangerSettingCard", component: Story, parameters: { nextjs: { appDirectory: true, }, }, + title: "blocks/Cards/DangerSettingCard", } satisfies Meta; export default meta; type Story = StoryObj; -export const Desktop: Story = { - args: {}, -}; - -export const Mobile: Story = { +export const Variants: Story = { args: {}, - parameters: { - viewport: mobileViewport("iphone14"), - }, }; function Story() { return ( -
+
{}} - isPending={false} confirmationDialog={{ - title: "This is confirmation title", description: "This is confirmation description", + title: "This is confirmation title", }} + description="This is a description" + isPending={false} + title="This is a title" /> {}} - isPending={true} confirmationDialog={{ - title: "This is confirmation title", description: "This is confirmation description", + title: "This is confirmation title", }} + description="This is a description" + isPending={true} + title="This is a title" />
diff --git a/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx b/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx index fedd7461d44..9df85b23723 100644 --- a/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx +++ b/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx @@ -1,4 +1,4 @@ -import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -6,27 +6,34 @@ import { DialogClose, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/Spinner"; import { cn } from "../../lib/utils"; +import { DynamicHeight } from "../ui/DynamicHeight"; export function DangerSettingCard(props: { title: string; className?: string; footerClassName?: string; - description: string; + description: React.ReactNode; buttonLabel: string; buttonOnClick: () => void; + isDisabled?: boolean; isPending: boolean; confirmationDialog: { title: string; - description: string; + description: React.ReactNode; + children?: React.ReactNode; + onClose?: () => void; }; children?: React.ReactNode; }) { + const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = + useState(false); + return (
- + { + setIsConfirmationDialogOpen(v); + if (!v) { + props.confirmationDialog.onClose?.(); + } + }} + open={isConfirmationDialogOpen} + > - - - - {props.confirmationDialog.title} - + + +
+ + + {props.confirmationDialog.title} + - - {props.confirmationDialog.description} - - + + {props.confirmationDialog.description} + + + {props.confirmationDialog.children} +
+
- +
- +
diff --git a/apps/dashboard/src/components/shared/DocLink.tsx b/apps/dashboard/src/@/components/blocks/DocLink.tsx similarity index 92% rename from apps/dashboard/src/components/shared/DocLink.tsx rename to apps/dashboard/src/@/components/blocks/DocLink.tsx index ff1bc49e4b4..3b8b37f01ff 100644 --- a/apps/dashboard/src/components/shared/DocLink.tsx +++ b/apps/dashboard/src/@/components/blocks/DocLink.tsx @@ -7,9 +7,10 @@ export function DocLink(props: { }) { return ( {props.label} diff --git a/apps/dashboard/src/@/components/blocks/ExportToCSVButton.tsx b/apps/dashboard/src/@/components/blocks/ExportToCSVButton.tsx index d3e209f5ed4..25a7fd40d69 100644 --- a/apps/dashboard/src/@/components/blocks/ExportToCSVButton.tsx +++ b/apps/dashboard/src/@/components/blocks/ExportToCSVButton.tsx @@ -1,12 +1,13 @@ -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Button } from "@/components/ui/button"; import { useMutation } from "@tanstack/react-query"; import { DownloadIcon } from "lucide-react"; +import Papa from "papaparse"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/Spinner"; import { cn } from "../../lib/utils"; export function ExportToCSVButton(props: { - getData: () => Promise<{ header: string[]; rows: string[][] }>; + getData: () => Promise<{ header: string[]; rows: string[][] } | string>; fileName: string; disabled?: boolean; className?: string; @@ -14,7 +15,12 @@ export function ExportToCSVButton(props: { const exportMutation = useMutation({ mutationFn: async () => { const data = await props.getData(); - exportToCSV(props.fileName, data); + if (typeof data === "string") { + exportToCSV(props.fileName, data); + } else { + const fileContent = convertToCSVFormat(data); + exportToCSV(props.fileName, fileContent); + } }, onError: () => { toast.error("Failed to download CSV"); @@ -28,37 +34,33 @@ export function ExportToCSVButton(props: { return ( ); } -function exportToCSV( - fileName: string, - data: { header: string[]; rows: string[][] }, -) { - const { header, rows } = data; - const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows - .map((e) => e.join(",")) - .join("\n")}`; +function convertToCSVFormat(data: { header: string[]; rows: string[][] }) { + return Papa.unparse({ + data: data.rows, + fields: data.header, + }); +} +function exportToCSV(fileName: string, fileContent: string) { + const csvContent = `data:text/csv;charset=utf-8,${fileContent}`; const encodedUri = encodeURI(csvContent); const link = document.createElement("a"); link.setAttribute("href", encodedUri); diff --git a/apps/dashboard/src/@/components/blocks/FileInput.tsx b/apps/dashboard/src/@/components/blocks/FileInput.tsx new file mode 100644 index 00000000000..5f734d4b3cf --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/FileInput.tsx @@ -0,0 +1,163 @@ +import { FilePlusIcon, UploadIcon } from "lucide-react"; +import { useCallback, useEffect, useRef } from "react"; +import { + type Accept, + type DropEvent, + type FileRejection, + useDropzone, +} from "react-dropzone"; +import type { ThirdwebClient } from "thirdweb"; +import { FilePreview } from "@/components/blocks/file-preview"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface IFileInputProps { + accept?: Accept; + setValue: (file: File) => void; + isDisabled?: boolean; + value?: string | File; + showUploadButton?: true; + previewMaxWidth?: string; + renderPreview?: (fileUrl: string) => React.ReactNode; + helperText?: string; + selectOrUpload?: "Select" | "Upload"; + isDisabledText?: string; + showPreview?: boolean; + children?: React.ReactNode; + className?: string; + disableHelperText?: boolean; + client: ThirdwebClient; +} + +export const FileInput: React.FC = ({ + setValue, + isDisabled, + accept, + showUploadButton, + value, + children, + helperText, + selectOrUpload = "Select", + isDisabledText = "Upload Disabled", + showPreview = true, + className, + previewMaxWidth, + client, + disableHelperText, +}) => { + const onDrop = useCallback< + ( + acceptedFiles: T[], + fileRejections: FileRejection[], + event: DropEvent, + ) => void + >( + (droppedFiles) => { + if (droppedFiles?.[0]) { + setValue(droppedFiles[0]); + } + }, + [setValue], + ); + const { getRootProps, getInputProps } = useDropzone({ + accept, + onDrop, + }); + + const helperTextOrFile = helperText + ? helperText + : accept && + Object.keys(accept).filter((k) => k.split("/")[0] !== "image") + .length === 1 + ? "image" + : "file"; + + const createdBlobUrl = useRef(null); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + return () => { + if (createdBlobUrl.current) { + URL.revokeObjectURL(createdBlobUrl.current); + } + }; + }, []); + + if (children) { + return ( +
+ {children} + +
+ ); + } + + return ( +
+ + {showPreview && + (isDisabled ? ( +
+
+ +

{isDisabledText}

+
+
+ ) : ( +
+ {value ? ( + + ) : ( +
+
+ +
+ {!disableHelperText && ( +

+ Upload {helperTextOrFile} +

+ )} +
+ )} +
+ ))} + + {showUploadButton ? ( +
+ {showUploadButton && ( + + )} +
+ ) : null} +
+ ); +}; diff --git a/apps/dashboard/src/@/components/blocks/FormFieldSetup.tsx b/apps/dashboard/src/@/components/blocks/FormFieldSetup.tsx index 346310925a9..ce1871d3fe0 100644 --- a/apps/dashboard/src/@/components/blocks/FormFieldSetup.tsx +++ b/apps/dashboard/src/@/components/blocks/FormFieldSetup.tsx @@ -1,7 +1,8 @@ -import { Label } from "@/components/ui/label"; -import { ToolTipLabel } from "@/components/ui/tooltip"; import { AsteriskIcon, InfoIcon } from "lucide-react"; import type React from "react"; +import { Label } from "@/components/ui/label"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "../../lib/utils"; export function FormFieldSetup(props: { htmlFor?: string; @@ -10,13 +11,22 @@ export function FormFieldSetup(props: { children: React.ReactNode; tooltip?: React.ReactNode; isRequired: boolean; + labelClassName?: string; + labelContainerClassName?: string; helperText?: React.ReactNode; className?: string; }) { return (
-
- +
+ {props.isRequired && ( diff --git a/apps/dashboard/src/@/components/blocks/GatedSwitch.stories.tsx b/apps/dashboard/src/@/components/blocks/GatedSwitch.stories.tsx new file mode 100644 index 00000000000..9d03cd68d61 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/GatedSwitch.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import type { Team } from "@/api/team/get-team"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { BadgeContainer } from "@/storybook/utils"; +import { GatedSwitch } from "./GatedSwitch"; + +const meta = { + component: Variants, + title: "Billing/GatedSwitch", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AllVariants: Story = { + args: { + theme: "dark", + }, +}; + +function Variants() { + const [requiredPlan, setRequiredPlan] = useState< + "free" | "growth" | "scale" | "pro" + >("scale"); + + const plans: Team["billingPlan"][] = [ + "free", + "starter", + "growth_legacy", + "growth", + "accelerate", + "scale", + "pro", + ]; + + return ( +
+
+ + +
+ + {plans.map((currentPlan) => ( + + + + ))} +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/GatedSwitch.tsx b/apps/dashboard/src/@/components/blocks/GatedSwitch.tsx new file mode 100644 index 00000000000..8ef75d27092 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/GatedSwitch.tsx @@ -0,0 +1,80 @@ +import { ExternalLinkIcon } from "lucide-react"; +import Link from "next/link"; +import type { Team } from "@/api/team/get-team"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { planToTierRecordForGating } from "@/constants/planToTierRecord"; +import { cn } from "@/lib/utils"; +import { getTeamPlanBadgeLabel, TeamPlanBadge } from "./TeamPlanBadge"; + +type SwitchProps = React.ComponentProps; + +type GatedSwitchProps = { + trackingLabel?: string; + currentPlan: Team["billingPlan"]; + requiredPlan: Team["billingPlan"]; + isLegacyPlan: boolean; + teamSlug: string; + switchProps?: SwitchProps; +}; + +export const GatedSwitch: React.FC = ( + props: GatedSwitchProps, +) => { + const isUpgradeRequired = + planToTierRecordForGating[props.currentPlan] < + planToTierRecordForGating[props.requiredPlan]; + + return ( + +

+ + {getTeamPlanBadgeLabel(props.requiredPlan, false)}+ + {" "} + plan required +

+

+ Upgrade your plan to use this feature +

+ +
+ +
+
+ ) : undefined + } + > +
+ {isUpgradeRequired && ( + + )} + +
+ + ); +}; diff --git a/apps/dashboard/src/@/components/blocks/Img.tsx b/apps/dashboard/src/@/components/blocks/Img.tsx index a5a2d9f52f5..e87b7fae364 100644 --- a/apps/dashboard/src/@/components/blocks/Img.tsx +++ b/apps/dashboard/src/@/components/blocks/Img.tsx @@ -1,85 +1,3 @@ -/* eslint-disable @next/next/no-img-element */ "use client"; -import { useRef, useState } from "react"; -import { useIsomorphicLayoutEffect } from "../../lib/useIsomorphicLayoutEffect"; -import { cn } from "../../lib/utils"; -type imgElementProps = React.DetailedHTMLProps< - React.ImgHTMLAttributes, - HTMLImageElement -> & { - skeleton?: React.ReactNode; - fallback?: React.ReactNode; - src: string | undefined; -}; - -export function Img(props: imgElementProps) { - const [_status, setStatus] = useState<"pending" | "fallback" | "loaded">( - "pending", - ); - const status = - props.src === undefined - ? "pending" - : props.src === "" - ? "fallback" - : _status; - const { className, fallback, skeleton, ...restProps } = props; - const defaultSkeleton =
; - const defaultFallback =
; - const imgRef = useRef(null); - - useIsomorphicLayoutEffect(() => { - const imgEl = imgRef.current; - if (!imgEl) { - return; - } - if (imgEl.complete) { - setStatus("loaded"); - } else { - function handleLoad() { - setStatus("loaded"); - } - imgEl.addEventListener("load", handleLoad); - return () => { - imgEl.removeEventListener("load", handleLoad); - }; - } - }, []); - - return ( -
- { - setStatus("fallback"); - }} - style={{ - opacity: status === "loaded" ? 1 : 0, - ...restProps.style, - }} - alt={restProps.alt || ""} - className={cn( - "fade-in-0 object-cover transition-opacity duration-300", - className, - )} - decoding="async" - /> - - {status !== "loaded" && ( -
*]:h-full [&>*]:w-full", - className, - )} - > - {status === "pending" && (skeleton || defaultSkeleton)} - {status === "fallback" && (fallback || defaultFallback)} -
- )} -
- ); -} +export { Img } from "@workspace/ui/components/img"; diff --git a/apps/dashboard/src/@/components/blocks/MobileSidebar.tsx b/apps/dashboard/src/@/components/blocks/MobileSidebar.tsx index 03de6d1c826..9dcdabc8337 100644 --- a/apps/dashboard/src/@/components/blocks/MobileSidebar.tsx +++ b/apps/dashboard/src/@/components/blocks/MobileSidebar.tsx @@ -1,11 +1,11 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { ChevronDownIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; -import { cn } from "../../lib/utils"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; import { RenderSidebarLinks, type SidebarBaseLink, @@ -19,30 +19,7 @@ export function MobileSidebar(props: { triggerClassName?: string; }) { const [isOpen, setIsOpen] = useState(false); - const pathname = usePathname(); - - const activeLink = useMemo(() => { - function isActive(link: SidebarBaseLink) { - if (link.exactMatch) { - return link.href === pathname; - } - return pathname?.startsWith(link.href); - } - - for (const link of props.links) { - if ("group" in link) { - for (const subLink of link.links) { - if (isActive(subLink)) { - return subLink; - } - } - } else { - if (isActive(link)) { - return link; - } - } - } - }, [props.links, pathname]); + const activeLink = useActiveSidebarLink(props.links); const defaultTrigger = (
); } @@ -61,16 +59,21 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) { ); } + if ("separator" in link) { + return ; + } + const isExternal = link.href.startsWith("http"); return ( + {link.icon && } {link.label} {isExternal && } diff --git a/apps/dashboard/src/@/components/blocks/SidebarLayout.stories.tsx b/apps/dashboard/src/@/components/blocks/SidebarLayout.stories.tsx index ae5c0641e41..5ea34677163 100644 --- a/apps/dashboard/src/@/components/blocks/SidebarLayout.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/SidebarLayout.stories.tsx @@ -1,31 +1,23 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { mobileViewport } from "../../../stories/utils"; +import type { Meta, StoryObj } from "@storybook/nextjs"; import { SidebarLayout } from "./SidebarLayout"; const meta = { - title: "blocks/SidebarLayout", component: Story, parameters: { nextjs: { appDirectory: true, }, }, + title: "blocks/SidebarLayout", } satisfies Meta; export default meta; type Story = StoryObj; -export const Desktop: Story = { +export const Variants: Story = { args: {}, }; -export const Mobile: Story = { - args: {}, - parameters: { - viewport: mobileViewport("iphone14"), - }, -}; - function Story() { return ( diff --git a/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx b/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx index 8c1490511d1..76f50bab4d0 100644 --- a/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx +++ b/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx @@ -1,6 +1,8 @@ +"use client"; + import { cn } from "../../lib/utils"; import { MobileSidebar } from "./MobileSidebar"; -import { Sidebar, type SidebarLink } from "./Sidebar"; +import { CustomSidebar, type SidebarLink } from "./Sidebar"; export function SidebarLayout(props: { sidebarLinks: SidebarLink[]; @@ -17,7 +19,10 @@ export function SidebarLayout(props: { props.className, )} > - + void; + setAbi?: (abi: string) => void; + placeholder?: string; + disabled?: boolean; + secondaryTextFormatter?: (sig: SignatureOption) => string; + className?: string; + multiSelect?: boolean; +} + +export function SignatureSelector({ + options, + value, + onChange, + setAbi, + placeholder = "Select or enter a signature", + disabled, + secondaryTextFormatter, + className, + multiSelect = false, +}: SignatureSelectorProps) { + const [searchValue, setSearchValue] = useState(""); + const inputRef = useRef(null); + + // Memoize options with formatted secondary text if provided + const formattedOptions = useMemo(() => { + return options.map((opt) => ({ + ...opt, + label: secondaryTextFormatter + ? `${opt.label} • ${secondaryTextFormatter(opt)}` + : opt.label, + })); + }, [options, secondaryTextFormatter]); + + // Handle both single and multi-select values + const currentValues = useMemo((): string[] => { + if (multiSelect) { + if (Array.isArray(value)) { + return value.filter( + (val): val is string => + val !== undefined && val !== null && val !== "", + ); + } else { + return value ? [value] : []; + } + } else { + if (Array.isArray(value)) { + return value.length > 0 && value[0] ? [value[0]] : []; + } else { + return value ? [value] : []; + } + } + }, [value, multiSelect]); + + // Check if the current values include custom values (not in options) + const customValues = useMemo((): string[] => { + return currentValues.filter( + (val): val is string => + val !== undefined && + val !== null && + val !== "" && + !options.some((opt) => opt.value === val), + ); + }, [currentValues, options]); + + // Add the custom values as options if needed + const allOptions = useMemo(() => { + const customOptions = customValues.map((val) => ({ + label: val, + value: val, + })); + return [...formattedOptions, ...customOptions]; + }, [formattedOptions, customValues]); + + // Multi-select or single-select MultiSelect wrapper + const handleSelectedValuesChange = useCallback( + (selected: string[]) => { + if (multiSelect) { + // Multi-select behavior + onChange(selected); + // For multi-select, we'll use the ABI from the first selected option that has one + const firstOptionWithAbi = selected.find((selectedValue) => { + const found = options.find((opt) => opt.value === selectedValue); + return found?.abi; + }); + if (setAbi && firstOptionWithAbi) { + const found = options.find((opt) => opt.value === firstOptionWithAbi); + setAbi(found?.abi || ""); + } + } else { + // Single-select behavior (maintain backward compatibility) + const selectedValue = + selected.length > 0 ? (selected[selected.length - 1] ?? "") : ""; + onChange(selectedValue); + const found = options.find((opt) => opt.value === selectedValue); + if (setAbi) { + setAbi(found?.abi || ""); + } + } + setSearchValue(""); + }, + [onChange, setAbi, options, multiSelect], + ); + + // Handle custom value entry + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && searchValue.trim()) { + if (!options.some((opt) => opt.value === searchValue.trim())) { + if (multiSelect) { + // Add to existing values for multi-select + const currentArray = Array.isArray(value) + ? value + : value + ? [value] + : []; + const filteredArray = currentArray.filter( + (val): val is string => val !== undefined && val !== null, + ); + const newValues = [...filteredArray, searchValue.trim()]; + onChange(newValues); + } else { + // Replace value for single-select + onChange(searchValue.trim()); + } + if (setAbi) setAbi(""); + setSearchValue(""); + // Optionally blur input + inputRef.current?.blur(); + } + } + }; + + // Custom render for MultiSelect's search input + const customSearchInput = ( + setSearchValue(e.target.value)} + onKeyDown={handleInputKeyDown} + placeholder={placeholder} + ref={inputRef} + type="text" + value={searchValue} + /> + ); + + return ( +
+ + option.label.toLowerCase().includes(searchTerm.toLowerCase()) || + option.value.toLowerCase().includes(searchTerm.toLowerCase()) + } + placeholder={placeholder} + renderOption={(option) => {option.label}} + searchPlaceholder={placeholder} + selectedValues={currentValues} + /> + {customValues.length > 0 && ( +
+ {multiSelect + ? `You entered ${customValues.length} custom signature${customValues.length > 1 ? "s" : ""}. Please provide the ABI below.` + : "You entered a custom signature. Please provide the ABI below."} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/SingleNetworkSelector.stories.tsx b/apps/dashboard/src/@/components/blocks/SingleNetworkSelector.stories.tsx index 8526e1eadaf..fcfd036149d 100644 --- a/apps/dashboard/src/@/components/blocks/SingleNetworkSelector.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/SingleNetworkSelector.stories.tsx @@ -1,41 +1,34 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/nextjs"; import { useState } from "react"; -import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils"; import { SingleNetworkSelector } from "./NetworkSelectors"; const meta = { - title: "blocks/Cards/SingleNetworkSelector", component: Story, parameters: { nextjs: { appDirectory: true, }, }, + title: "blocks/Cards/SingleNetworkSelector", } satisfies Meta; export default meta; type Story = StoryObj; -export const Desktop: Story = { +export const Variants: Story = { args: {}, }; -export const Mobile: Story = { - args: {}, - parameters: { - viewport: mobileViewport("iphone14"), - }, -}; - function Story() { return ( -
- - +
+ +
); @@ -51,8 +44,9 @@ function Variant(props: { ); diff --git a/apps/dashboard/src/@/components/blocks/StepsCard.stories.tsx b/apps/dashboard/src/@/components/blocks/StepsCard.stories.tsx new file mode 100644 index 00000000000..d7fafb3df48 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/StepsCard.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { StepsCard } from "@/components/blocks/StepsCard"; +import { BadgeContainer } from "@/storybook/utils"; + +const meta = { + component: Component, + title: "Blocks/Cards/StepsCard", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const defaultCardTitle = "Get started with deploying contracts"; +const defaultCardDescription = + "This guide will help you to start deploying contracts on-chain in just a few minutes"; + +export const Variants: Story = { + args: { + cardDescription: defaultCardDescription, + cardTitle: defaultCardTitle, + }, +}; + +function stepStub(options: { + id: number; + completed: boolean; + showCompletedChildren?: boolean; + description?: string; +}) { + return { + children: , + completed: options.completed, + description: + options.description ?? `This is step ${options.id} description`, + showCompletedChildren: options.showCompletedChildren, + title: `This is step ${options.id} title`, + }; +} + +function Component(props: { cardTitle: string; cardDescription: string }) { + const { cardTitle, cardDescription } = props; + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +} + +function ChildrenPlaceholder() { + return ( +
+ ); +} diff --git a/apps/dashboard/src/components/dashboard/StepsCard.tsx b/apps/dashboard/src/@/components/blocks/StepsCard.tsx similarity index 88% rename from apps/dashboard/src/components/dashboard/StepsCard.tsx rename to apps/dashboard/src/@/components/blocks/StepsCard.tsx index 308377e3972..eba5a9d2e8d 100644 --- a/apps/dashboard/src/components/dashboard/StepsCard.tsx +++ b/apps/dashboard/src/@/components/blocks/StepsCard.tsx @@ -1,14 +1,15 @@ -import { Progress } from "@/components/ui/progress"; -import { cn } from "@/lib/utils"; import { CheckIcon } from "lucide-react"; import { type JSX, useMemo } from "react"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; -type Step = { +export type Step = { title: string | JSX.Element; description?: string; completed: boolean; children: React.ReactNode; showCompletedChildren?: boolean; + showIncompleteChildren?: boolean; }; interface StepsCardProps { @@ -36,7 +37,7 @@ export const StepsCard: React.FC = ({ return (
{/* Title + Desc */} -

+

{title}

@@ -48,7 +49,7 @@ export const StepsCard: React.FC = ({

)} - +

{lastStepCompleted + 1}/{steps.length} tasks completed @@ -57,8 +58,11 @@ export const StepsCard: React.FC = ({

{steps.map(({ children, ...step }, index) => { + const isActiveStep = index === lastStepCompleted + 1; const showChildren = - !step.completed || (step.completed && step.showCompletedChildren); + isActiveStep || + (!step.completed && step.showIncompleteChildren !== false) || + (step.completed && step.showCompletedChildren); return (
= ({ key={index} >
@@ -103,10 +107,7 @@ export const StepsCard: React.FC = ({ ); }; -function StepNumberBadge(props: { - number: number; - isCompleted: boolean; -}) { +function StepNumberBadge(props: { number: number; isCompleted: boolean }) { return (
{props.isCompleted ? ( diff --git a/apps/dashboard/src/components/shared/TWTable.tsx b/apps/dashboard/src/@/components/blocks/TWTable.tsx similarity index 97% rename from apps/dashboard/src/components/shared/TWTable.tsx rename to apps/dashboard/src/@/components/blocks/TWTable.tsx index d76bfd2e0f3..b6e90529dd9 100644 --- a/apps/dashboard/src/components/shared/TWTable.tsx +++ b/apps/dashboard/src/@/components/blocks/TWTable.tsx @@ -1,6 +1,17 @@ +/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */ "use client"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + type PaginationState, + type TableOptions, + useReactTable, +} from "@tanstack/react-table"; +import { EllipsisVerticalIcon, MoveRightIcon } from "lucide-react"; +import pluralize from "pluralize"; +import { type ReactNode, type SetStateAction, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -8,6 +19,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Spinner } from "@/components/ui/Spinner"; import { Separator } from "@/components/ui/separator"; import { Table, @@ -19,17 +31,6 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; -import { - type ColumnDef, - type PaginationState, - type TableOptions, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { EllipsisVerticalIcon, MoveRightIcon } from "lucide-react"; -import pluralize from "pluralize"; -import { type ReactNode, type SetStateAction, useMemo, useState } from "react"; type CtaMenuItem = { icon?: ReactNode; @@ -107,18 +108,18 @@ export function TWTable(tableProps: TWTableProps) { () => tableProps.pagination ? { + manualPagination: true, + onPaginationChange: setPagination, pageCount: Math.ceil(slicedData.length / pageSize), state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, } : {}, [pageSize, pagination, slicedData.length, tableProps.pagination], ); const table = useReactTable({ - data: slicedData, columns: tableProps.columns, + data: slicedData, ...paginationOptions, getCoreRowModel: getCoreRowModel(), // TODO - add filtering? @@ -135,7 +136,7 @@ export function TWTable(tableProps: TWTableProps) { {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - + {header.isPlaceholder ? null : (
{flexRender( @@ -167,16 +168,16 @@ export function TWTable(tableProps: TWTableProps) { role="group" {...(tableProps.onRowClick ? { - style: { - cursor: "pointer", - }, - onClick: () => tableProps.onRowClick?.(row.original), - _hover: { bg: "blackAlpha.50" }, _dark: { _hover: { bg: "whiteAlpha.50", }, }, + _hover: { bg: "blackAlpha.50" }, + onClick: () => tableProps.onRowClick?.(row.original), + style: { + cursor: "pointer", + }, } : {})} className={tableProps.bodyRowClassName} @@ -203,9 +204,9 @@ export function TWTable(tableProps: TWTableProps) { @@ -215,12 +216,12 @@ export function TWTable(tableProps: TWTableProps) { ({ icon, text, onClick, isDestructive }) => { return ( onClick(row.original)} className={cn( "min-w-[170px] cursor-pointer gap-3 px-3 py-3", isDestructive && "!text-destructive-text", )} + key={text} + onClick={() => onClick(row.original)} > {icon} {text} @@ -252,24 +253,24 @@ export function TWTable(tableProps: TWTableProps) { {!tableProps.isPending && tableProps.data.length === 0 && tableProps.isFetched && ( -
+

- No {pluralize(tableProps.title, 0, false)} found. + No {pluralize(tableProps.title, 0, false)} found

)} tableProps.showMore.pageSize } - pageSize={tableProps.showMore?.pageSize || -1} + shouldShowMore={slicedData.length < tableProps.data.length} showMoreLimit={showMoreLimit} - setShowMoreLimit={setShowMoreLimit} /> ); @@ -305,18 +306,18 @@ const ShowMoreButton: React.FC = ({
{shouldShowMore && ( )} {shouldShowLess && ( diff --git a/apps/dashboard/src/@/components/blocks/TeamPlanBadge.tsx b/apps/dashboard/src/@/components/blocks/TeamPlanBadge.tsx new file mode 100644 index 00000000000..d44e9a98e87 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/TeamPlanBadge.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { Team } from "@/api/team/get-team"; +import { Badge, type BadgeProps } from "@/components/ui/badge"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { cn } from "@/lib/utils"; + +const teamPlanToBadgeVariant: Record< + Team["billingPlan"], + BadgeProps["variant"] +> = { + // green + accelerate: "success", + // gray + free: "secondary", + growth: "success", + + growth_legacy: "warning", + // blue + pro: "default", + scale: "success", + // yellow + starter: "warning", +}; + +export function getTeamPlanBadgeLabel( + plan: Team["billingPlan"], + isLegacyPlan: boolean, +) { + if (plan === "growth_legacy") { + return "Growth - Legacy"; + } + + if (isLegacyPlan) { + return `${plan} - Legacy`; + } + + return plan; +} + +export function TeamPlanBadge(props: { + teamSlug: string; + plan: Team["billingPlan"]; + isLegacyPlan: boolean; + className?: string; + postfix?: string; +}) { + const router = useDashboardRouter(); + + function handleNavigateToBilling(e: React.MouseEvent | React.KeyboardEvent) { + e.stopPropagation(); + e.preventDefault(); + + if (props.isLegacyPlan) { + router.push(`/team/${props.teamSlug}/~/billing`); + return; + } + + if (props.plan !== "free") { + return; + } + router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`); + } + + return ( + { + if (e.key === "Enter" || e.key === " ") { + handleNavigateToBilling(e); + } + }} + role={props.plan === "free" || props.isLegacyPlan ? "button" : undefined} + tabIndex={props.plan === "free" || props.isLegacyPlan ? 0 : undefined} + variant={ + props.isLegacyPlan ? "warning" : teamPlanToBadgeVariant[props.plan] + } + > + {`${getTeamPlanBadgeLabel(props.plan, props.isLegacyPlan)}${props.postfix || ""}`} + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx new file mode 100644 index 00000000000..862785ad400 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils"; +import { TokenSelector } from "./TokenSelector"; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "blocks/Cards/TokenSelector", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story() { + return ( +
+ +
+ ); +} + +function Variant(props: { label: string; selectedChainId?: number }) { + const [token, setToken] = useState< + | { + address: string; + chainId: number; + } + | undefined + >(undefined); + + return ( + + { + setToken({ + address: v.address, + chainId: v.chainId, + }); + }} + selectedToken={token} + showCheck={false} + /> + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx new file mode 100644 index 00000000000..00f66951625 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx @@ -0,0 +1,210 @@ +import { useCallback, useMemo } from "react"; +import { + getAddress, + NATIVE_TOKEN_ADDRESS, + type ThirdwebClient, +} from "thirdweb"; +import { shortenAddress } from "thirdweb/utils"; +import type { TokenMetadata } from "@/api/universal-bridge/types"; +import { Img } from "@/components/blocks/Img"; +import { SelectWithSearch } from "@/components/blocks/select-with-search"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/Spinner"; +import { fallbackChainIcon } from "@/constants/chain"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { useTokensData } from "@/hooks/tokens"; +import { cn } from "@/lib/utils"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; + +type Option = { label: string; value: string }; + +const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); + +export function TokenSelector(props: { + selectedToken: { chainId: number; address: string } | undefined; + onChange: (token: TokenMetadata) => void; + className?: string; + popoverContentClassName?: string; + chainId?: number; + side?: "left" | "right" | "top" | "bottom"; + disableAddress?: boolean; + align?: "center" | "start" | "end"; + placeholder?: string; + client: ThirdwebClient; + disabled?: boolean; + enabled?: boolean; + showCheck: boolean; + addNativeTokenIfMissing: boolean; +}) { + const tokensQuery = useTokensData({ + chainId: props.chainId, + enabled: props.enabled, + }); + + const { idToChain } = useAllChainsData(); + + const tokens = useMemo(() => { + if (!tokensQuery.data) { + return []; + } + + if (props.addNativeTokenIfMissing) { + const hasNativeToken = tokensQuery.data.some( + (token) => token.address === checksummedNativeTokenAddress, + ); + + if (!hasNativeToken && props.chainId) { + return [ + { + address: checksummedNativeTokenAddress, + chainId: props.chainId, + decimals: 18, + name: + idToChain.get(props.chainId)?.nativeCurrency.name ?? + "Native Token", + symbol: + idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH", + } satisfies TokenMetadata, + ...tokensQuery.data, + ]; + } + } + return tokensQuery.data; + }, [ + tokensQuery.data, + props.chainId, + props.addNativeTokenIfMissing, + idToChain, + ]); + + const addressChainToToken = useMemo(() => { + const value = new Map(); + for (const token of tokens) { + value.set(`${token.chainId}:${token.address}`, token); + } + return value; + }, [tokens]); + + const options = useMemo(() => { + return ( + tokens.map((token) => { + return { + label: token.symbol, + value: `${token.chainId}:${token.address}`, + }; + }) || [] + ); + }, [tokens]); + + const searchFn = useCallback( + (option: Option, searchValue: string) => { + const token = addressChainToToken.get(option.value); + if (!token) { + return false; + } + + if (Number.isInteger(Number(searchValue))) { + return String(token.chainId).startsWith(searchValue); + } + return ( + token.name.toLowerCase().includes(searchValue.toLowerCase()) || + token.symbol.toLowerCase().includes(searchValue.toLowerCase()) || + token.address.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, + [addressChainToToken], + ); + + const renderOption = useCallback( + (option: Option) => { + const token = addressChainToToken.get(option.value); + if (!token) { + return option.label; + } + const resolvedSrc = token.iconUri + ? resolveSchemeWithErrorHandler({ + client: props.client, + uri: token.iconUri, + }) + : fallbackChainIcon; + + return ( +
+ + } + key={resolvedSrc} + loading={"lazy"} + // eslint-disable-next-line @next/next/no-img-element + skeleton={ +
+ } + src={resolvedSrc} + /> + {token.symbol} + + + {!props.disableAddress && ( + + Address + {shortenAddress(token.address, 4)} + + )} +
+ ); + }, + [addressChainToToken, props.disableAddress, props.client], + ); + + const selectedValue = props.selectedToken + ? `${props.selectedToken.chainId}:${props.selectedToken.address}` + : undefined; + + // if selected value is not in options, add it + if ( + selectedValue && + !options.find((option) => option.value === selectedValue) + ) { + options.push({ + label: props.selectedToken?.address || "Unknown", + value: selectedValue, + }); + } + + return ( + { + const token = addressChainToToken.get(tokenAddress); + if (!token) { + return; + } + props.onChange(token); + }} + options={options} + overrideSearchFn={searchFn} + placeholder={ + tokensQuery.isPending ? ( +
+ + Loading tokens +
+ ) : ( + props.placeholder || "Select Token" + ) + } + popoverContentClassName={props.popoverContentClassName} + renderOption={renderOption} + searchPlaceholder="Search by name or symbol" + showCheck={props.showCheck} + side={props.side} + value={tokensQuery.isPending ? undefined : selectedValue} + /> + ); +} diff --git a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.stories.tsx b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.stories.tsx new file mode 100644 index 00000000000..693f368568b --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { ArrowRightIcon, RocketIcon, StarIcon } from "lucide-react"; +import { BadgeContainer } from "@/storybook/utils"; +import { UpsellBannerCard } from "./UpsellBannerCard"; + +function Story() { + return ( +
+ + , + link: "#", + text: "View plans", + }} + description="Upgrade to increase limits and access advanced features." + icon={} + title="Unlock more with thirdweb" + /> + + + + , + link: "#", + text: "Upgrade storage", + }} + description="Add additional space to your account." + icon={} + title="Need more storage?" + /> + + + + , + link: "#", + text: "Request access", + }} + description="Get early access to experimental features." + title="Join the beta" + /> + +
+ ); +} + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "blocks/Banners/UpsellBannerCard", +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; diff --git a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx new file mode 100644 index 00000000000..a9461a8a6e8 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx @@ -0,0 +1,140 @@ +"use client"; + +import Link from "next/link"; +import type React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const ACCENT = { + blue: { + bgFrom: "from-blue-50 dark:from-blue-900/20", + blur: "bg-blue-600", + border: "border-blue-600 dark:border-blue-700", + btn: "bg-blue-600 text-white hover:bg-blue-700", + desc: "text-blue-800 dark:text-blue-300", + iconBg: "bg-blue-600 text-white", + title: "text-blue-900 dark:text-blue-200", + }, + green: { + bgFrom: "from-green-50 dark:from-green-900/20", + blur: "bg-green-600", + border: "border-green-600 dark:border-green-700", + btn: "bg-green-600 text-white hover:bg-green-700", + desc: "text-green-800 dark:text-green-300", + iconBg: "bg-green-600 text-white", + title: "text-green-900 dark:text-green-200", + }, + purple: { + bgFrom: "from-purple-50 dark:from-purple-900/20", + blur: "bg-purple-600", + border: "border-purple-600 dark:border-purple-700", + btn: "bg-purple-600 text-white hover:bg-purple-700", + desc: "text-purple-800 dark:text-purple-300", + iconBg: "bg-purple-600 text-white", + title: "text-purple-900 dark:text-purple-200", + }, +} as const; + +type UpsellBannerCardProps = { + title: React.ReactNode; + description: React.ReactNode; + cta?: + | { + text: React.ReactNode; + icon?: React.ReactNode; + target?: "_blank"; + link: string; + } + | { + text: React.ReactNode; + icon?: React.ReactNode; + onClick: () => void; + }; + accentColor?: keyof typeof ACCENT; + icon?: React.ReactNode; +}; + +export function UpsellBannerCard(props: UpsellBannerCardProps) { + const color = ACCENT[props.accentColor || "green"]; + + return ( +
+ {/* Decorative blur */} +
+ +
+
+ {props.icon ? ( + + ) : null} + +
+

+ {props.title} +

+

{props.description}

+
+
+ + {props.cta && "target" in props.cta ? ( + + ) : props.cta && "onClick" in props.cta ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/app-footer.tsx b/apps/dashboard/src/@/components/blocks/app-footer.tsx deleted file mode 100644 index d40dedab7dd..00000000000 --- a/apps/dashboard/src/@/components/blocks/app-footer.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { ThirdwebMiniLogo } from "app/components/ThirdwebMiniLogo"; -import { DiscordIcon } from "components/icons/brand-icons/DiscordIcon"; -import { GithubIcon } from "components/icons/brand-icons/GithubIcon"; -import { InstagramIcon } from "components/icons/brand-icons/InstagramIcon"; -import { LinkedInIcon } from "components/icons/brand-icons/LinkedinIcon"; -import { RedditIcon } from "components/icons/brand-icons/RedditIcon"; -import { TiktokIcon } from "components/icons/brand-icons/TiktokIcon"; -import { XIcon } from "components/icons/brand-icons/XIcon"; -import { YoutubeIcon } from "components/icons/brand-icons/YoutubeIcon"; -import Link from "next/link"; - -type AppFooterProps = { - className?: string; -}; - -export function AppFooter(props: AppFooterProps) { - return ( -
-
- {/* top row */} -
-
- -

- © {new Date().getFullYear()} thirdweb -

-
-
- - - - - - - - -
-
- {/* bottom row */} -
- - Home - - - Blog - - - Changelog - - - Feedback - - - Privacy Policy - - - Terms of Service - - - - Chain List - -
-
-
- ); -} diff --git a/apps/dashboard/src/@/components/blocks/Avatars/GradientBlobbie.tsx b/apps/dashboard/src/@/components/blocks/avatar/GradientBlobbie.tsx similarity index 100% rename from apps/dashboard/src/@/components/blocks/Avatars/GradientBlobbie.tsx rename to apps/dashboard/src/@/components/blocks/avatar/GradientBlobbie.tsx diff --git a/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.stories.tsx b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.stories.tsx new file mode 100644 index 00000000000..397ebef51cd --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.stories.tsx @@ -0,0 +1,137 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils"; +import { Button } from "../../ui/button"; +import { GradientAvatar } from "./gradient-avatar"; + +const meta = { + component: Story, + title: "blocks/Avatars/GradientAvatar", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story() { + return ( +
+

All images below are set with size-20 className

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +function ToggleTest() { + const [data, setData] = useState( + undefined, + ); + + return ( +
+ + +

Src+ID is: {data ? "set" : "not set"}

+ + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.tsx b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.tsx new file mode 100644 index 00000000000..b2ab5ae234c --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/avatar/gradient-avatar.tsx @@ -0,0 +1,27 @@ +import type { ThirdwebClient } from "thirdweb"; +import { Img } from "@/components/blocks/Img"; +import { cn } from "@/lib/utils"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; +import { GradientBlobbie } from "./GradientBlobbie"; + +export function GradientAvatar(props: { + src: string | undefined; + id: string | undefined; + className: string; + client: ThirdwebClient; +}) { + const resolvedSrc = props.src + ? resolveSchemeWithErrorHandler({ + client: props.client, + uri: props.src, + }) + : props.src; + + return ( + : undefined} + src={resolvedSrc} + /> + ); +} diff --git a/apps/dashboard/src/@/components/blocks/avatar/project-avatar.stories.tsx b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.stories.tsx new file mode 100644 index 00000000000..63ee9dfbc13 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils"; +import { Button } from "../../ui/button"; +import { ProjectAvatar } from "./project-avatar"; + +const meta = { + component: Story, + parameters: {}, + title: "blocks/Avatars/ProjectAvatar", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story() { + return ( +
+

All images below are set with size-6 className

+ + + + + + + + + + +
+ ); +} + +function ToggleTest() { + const [data, setData] = useState( + undefined, + ); + + return ( +
+ + +

Src+Name is: {data ? "set" : "not set"}

+ + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/avatar/project-avatar.tsx b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.tsx new file mode 100644 index 00000000000..ebd02af7a09 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/avatar/project-avatar.tsx @@ -0,0 +1,29 @@ +import { BoxIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; +import { Img } from "@/components/blocks/Img"; +import { cn } from "@/lib/utils"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; + +export function ProjectAvatar(props: { + src: string | undefined; + className: string | undefined; + client: ThirdwebClient; +}) { + return ( + {""} + +
+ } + src={ + resolveSchemeWithErrorHandler({ + client: props.client, + uri: props.src, + }) || "" + } + /> + ); +} diff --git a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx index dc2fcc7524f..bdc9e76d304 100644 --- a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx @@ -1,5 +1,19 @@ "use client"; +import { format } from "date-fns"; +import { useMemo } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + EmptyChartState, + LoadingChartState, +} from "@/components/analytics/empty-chart-state"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { type ChartConfig, ChartContainer, @@ -8,95 +22,168 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { formatDate } from "date-fns"; -import { useMemo } from "react"; -import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; -import { - EmptyChartState, - LoadingChartState, -} from "../../../../components/analytics/empty-chart-state"; +import { cn } from "@/lib/utils"; type ThirdwebAreaChartProps = { + header?: { + title: string; + description?: string; + titleClassName?: string; + headerClassName?: string; + icon?: React.ReactNode; + }; + customHeader?: React.ReactNode; // chart config config: TConfig; data: Array & { time: number | string | Date }>; showLegend?: boolean; + yAxis?: boolean; + xAxis?: { + showHour?: boolean; + }; + + variant?: "stacked" | "individual"; + // chart className chartClassName?: string; isPending: boolean; + className?: string; + cardContentClassName?: string; + hideLabel?: boolean; + toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode; + toolTipValueFormatter?: (value: unknown) => React.ReactNode; + emptyChartState?: React.ReactElement; + margin?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + }; }; export function ThirdwebAreaChart( props: ThirdwebAreaChartProps, ) { const configKeys = useMemo(() => Object.keys(props.config), [props.config]); + return ( -
- - {props.isPending ? ( - - ) : props.data.length === 0 ? ( - - ) : ( - - - formatDate(new Date(value), "MMM dd")} - /> - } /> - - {configKeys.map((key) => ( - - - + {props.header && ( + + {props.header.icon} + + {props.header.title} + + {props.header.description && ( + {props.header.description} + )} + + )} + + {props.customHeader && props.customHeader} + + + + {props.isPending ? ( + + ) : props.data.length === 0 ? ( + + ) : ( + + + {props.yAxis && } + + format( + new Date(value), + props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd", + ) + } + tickLine={false} + tickMargin={20} + /> + - - ))} - - {configKeys.map((key) => ( - - ))} + + {configKeys.map((key) => ( + + + + + ))} + + {configKeys.map((key) => + key === "maxLine" ? ( + + ) : ( + + ), + )} - {props.showLegend && ( - } className="pt-8" /> - )} - - )} - -
+ {props.showLegend && ( + } + /> + )} + + )} + + + ); } diff --git a/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.stories.tsx b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.stories.tsx new file mode 100644 index 00000000000..0f38e4e5006 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.stories.tsx @@ -0,0 +1,180 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { InAppWalletAuth } from "thirdweb/wallets"; +import { BadgeContainer } from "@/storybook/utils"; +import { AutoMergeBarChart, type StatData } from "./automerge-barchart"; + +const meta = { + component: Component, + title: "Charts/AutoMergeBarChart", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Component() { + const title = "This is Title"; + const description = + "This is an example of a description about user wallet usage chart"; + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +const authMethodsToPickFrom: InAppWalletAuth[] = [ + "google", + "apple", + "facebook", + "discord", + "line", + "x", + "tiktok", + "epic", + "coinbase", + "farcaster", + "telegram", + "github", + "twitch", + "steam", + "guest", + "email", + "phone", + "passkey", + "wallet", +]; + +const pickRandomAuthMethod = () => { + const picked = + authMethodsToPickFrom[ + Math.floor(Math.random() * authMethodsToPickFrom.length) + ] || "google"; + + const capitalized = picked.charAt(0).toUpperCase() + picked.slice(1); + return capitalized; +}; + +function createStatsStub(days: number): StatData[] { + const stubbedData: StatData[] = []; + + let d = days; + while (d !== 0) { + const uniqueWallets = Math.floor(Math.random() * 100); + stubbedData.push({ + label: pickRandomAuthMethod(), + date: new Date(2024, 11, d).toLocaleString(), + count: uniqueWallets, + }); + + if (Math.random() > 0.7) { + d--; + } + } + + return stubbedData; +} diff --git a/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.tsx b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.tsx new file mode 100644 index 00000000000..b930a5572d9 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/charts/automerge-barchart.tsx @@ -0,0 +1,173 @@ +"use client"; +import { format } from "date-fns"; +import { useMemo } from "react"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; +import type { ChartConfig } from "@/components/ui/chart"; +import { TotalValueChartHeader } from "./chart-header"; + +export type StatData = { + date: string; + count: number; + label: string; +}; + +type ChartData = Record & { + time: string; // human readable date +}; + +const defaultLabel = "Unknown"; + +// merges the multiple stats in single bar if they share the same date +// shows the top `maxLabelsToShow` labels, merge the rest into "Others" + +export function AutoMergeBarChart(props: { + stats: StatData[]; + isPending: boolean; + title: string; + emptyChartState: React.ReactElement | undefined; + description: string | undefined; + maxLabelsToShow: number; + exportButton: + | { + fileName: string; + } + | undefined; + viewMoreLink: string | undefined; +}) { + const { stats, maxLabelsToShow } = props; + + const { chartConfig, chartData } = useMemo(() => { + const _chartConfig: ChartConfig = {}; + const _chartDataMap: Map = new Map(); + const labelToCountMap: Map = new Map(); + // for each stat, add it in _chartDataMap + for (const stat of stats) { + const chartData = _chartDataMap.get(stat.date); + + // if no data for current day - create new entry + if (!chartData && stat.count > 0) { + _chartDataMap.set(stat.date, { + time: stat.date, + [stat.label || defaultLabel]: stat.count, + } as ChartData); + } else if (chartData) { + chartData[stat.label || defaultLabel] = + (chartData[stat.label || defaultLabel] || 0) + stat.count; + } + + labelToCountMap.set( + stat.label || defaultLabel, + stat.count + (labelToCountMap.get(stat.label || defaultLabel) || 0), + ); + } + + const labelsSorted = Array.from(labelToCountMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map((w) => w[0]); + + const labelsToShow = labelsSorted.slice(0, maxLabelsToShow); + const labelsToShowAsOther = labelsSorted.slice(maxLabelsToShow); + + // replace chainIdsToTagAsOther chainId with "other" + for (const data of _chartDataMap.values()) { + for (const dataKey in data) { + if (labelsToShowAsOther.includes(dataKey)) { + data.others = (data.others || 0) + (data[dataKey] || 0); + delete data[dataKey]; + } + } + } + + labelsToShow.forEach((walletType, i) => { + _chartConfig[walletType] = { + color: `hsl(var(--chart-${(i % 10) + 1}))`, + label: labelsToShow[i], + }; + }); + + // Add Other + if (labelsToShow.length >= maxLabelsToShow) { + labelsToShow.push("others"); + _chartConfig.others = { + color: "hsl(var(--muted-foreground))", + label: "Others", + }; + } + + return { + chartConfig: _chartConfig, + chartData: Array.from(_chartDataMap.values()).sort( + (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), + ), + }; + }, [stats, maxLabelsToShow]); + + const uniqueLabels = Object.keys(chartConfig); + + const total = useMemo(() => { + return props.stats.reduce((acc, stat) => acc + stat.count, 0); + }, [props.stats]); + + return ( + + ) : ( +
+
+

+ {props.title} +

+ {props.description && ( +

+ {props.description} +

+ )} +
+ + {props.exportButton && props.stats.length > 0 && ( + { + // Shows the number of each type of wallet connected on all dates + const header = ["Time", ...uniqueLabels]; + const rows = chartData.map((data) => { + const { time, ...rest } = data; + return [ + new Date(time).toISOString(), + ...uniqueLabels.map((w) => (rest[w] || 0).toString()), + ]; + }); + return { header, rows }; + }} + /> + )} +
+ ) + } + data={chartData} + emptyChartState={props.emptyChartState} + hideLabel={false} + isPending={props.isPending} + showLegend + toolTipLabelFormatter={(_v, item) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return format(new Date(time), "MMM d, yyyy"); + } + return undefined; + }} + variant="stacked" + /> + ); +} diff --git a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx index 42de009738b..cdf1c7a6604 100644 --- a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx @@ -1,7 +1,12 @@ "use client"; +import { format } from "date-fns"; +import { useMemo } from "react"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; - +import { + EmptyChartState, + LoadingChartState, +} from "@/components/analytics/empty-chart-state"; import { Card, CardContent, @@ -17,18 +22,16 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { formatDate } from "date-fns"; -import { useMemo } from "react"; -import { - EmptyChartState, - LoadingChartState, -} from "../../../../components/analytics/empty-chart-state"; -import { cn } from "../../../lib/utils"; +import { cn } from "@/lib/utils"; type ThirdwebBarChartProps = { // metadata - title: string; - description?: string; + header?: { + title: string; + description?: string; + titleClassName?: string; + }; + customHeader?: React.ReactNode; // chart config config: TConfig; data: Array & { time: number | string | Date }>; @@ -38,8 +41,13 @@ type ThirdwebBarChartProps = { chartClassName?: string; isPending: boolean; toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode; + toolTipValueFormatter?: (value: unknown) => React.ReactNode; hideLabel?: boolean; - titleClassName?: string; + emptyChartState?: React.ReactElement; + className?: string; + xAxis?: { + showHour?: boolean; + }; }; export function ThirdwebBarChart( @@ -51,30 +59,40 @@ export function ThirdwebBarChart( props.variant || configKeys.length > 4 ? "stacked" : "grouped"; return ( - - - - {props.title} - - {props.description && ( - {props.description} - )} - - - + + {props.header && ( + + + {props.header.title} + + {props.header.description && ( + {props.header.description} + )} + + )} + + {props.customHeader && props.customHeader} + + + {props.isPending ? ( ) : props.data.length === 0 ? ( - + ) : ( + format( + new Date(value), + props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd", + ) + } tickLine={false} - axisLine={false} tickMargin={10} - tickFormatter={(value) => formatDate(new Date(value), "MMM d")} /> ( props.hideLabel !== undefined ? props.hideLabel : true } labelFormatter={props.toolTipLabelFormatter} + valueFormatter={props.toolTipValueFormatter} /> } /> {props.showLegend && ( - } /> + } + /> )} - {configKeys.map((key, idx) => ( + {configKeys.map((key) => ( ))} diff --git a/apps/dashboard/src/@/components/blocks/charts/chart-header.tsx b/apps/dashboard/src/@/components/blocks/charts/chart-header.tsx new file mode 100644 index 00000000000..9172ab7d3aa --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/charts/chart-header.tsx @@ -0,0 +1,60 @@ +import { ArrowUpRightIcon } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { SkeletonContainer } from "@/components/ui/skeleton"; + +export function TotalValueChartHeader(props: { + total: number; + title: string; + isUsd?: boolean; + isPending: boolean; + viewMoreLink: string | undefined; +}) { + return ( +
+
+ { + return ( +

+ {props.isUsd + ? compactUSDFormatter.format(value) + : compactNumberFormatter.format(value)} +

+ ); + }} + /> + +

{props.title}

+
+ + {props.viewMoreLink && ( + + )} +
+ ); +} + +const compactNumberFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 2, +}); + +const compactUSDFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 2, + style: "currency", + currency: "USD", +}); diff --git a/apps/dashboard/src/@/components/blocks/client-only.tsx b/apps/dashboard/src/@/components/blocks/client-only.tsx new file mode 100644 index 00000000000..26b36f6d503 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/client-only.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { type ReactNode, useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ClientOnlyProps { + /** + * Use this to server render a skeleton or loading state + */ + ssr: ReactNode; + className?: string; + children: ReactNode; +} + +export const ClientOnly: React.FC = ({ + children, + ssr, + className, +}) => { + const hasMounted = useIsClientMounted(); + + if (!hasMounted) { + return <> {ssr} ; + } + + return ( +
+ {children} +
+ ); +}; + +function useIsClientMounted() { + const [hasMounted, setHasMounted] = useState(false); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + setHasMounted(true); + }, []); + + return hasMounted; +} diff --git a/apps/dashboard/src/@/components/blocks/code-segment.client.tsx b/apps/dashboard/src/@/components/blocks/code-segment.client.tsx deleted file mode 100644 index 594a088bb1d..00000000000 --- a/apps/dashboard/src/@/components/blocks/code-segment.client.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; -import { CodeClient } from "@/components/ui/code/code.client"; -import type React from "react"; -import { type Dispatch, type SetStateAction, useMemo } from "react"; -import { TabButtons } from "../ui/tabs"; - -export type CodeEnvironment = - | "javascript" - | "typescript" - | "react" - | "react-native" - | "unity"; - -type SupportedEnvironment = { - environment: CodeEnvironment; - title: string; -}; - -type CodeSnippet = Partial>; - -const Environments: SupportedEnvironment[] = [ - { - environment: "javascript", - title: "JavaScript", - }, - { - environment: "typescript", - title: "TypeScript", - }, - { - environment: "react", - title: "React", - }, - { - environment: "react-native", - title: "React Native", - }, - { - environment: "unity", - title: "Unity", - }, -]; - -interface CodeSegmentProps { - snippet: CodeSnippet; - environment: CodeEnvironment; - setEnvironment: - | Dispatch> - | ((language: CodeEnvironment) => void); - isInstallCommand?: boolean; - hideTabs?: boolean; - onlyTabs?: boolean; -} - -export const CodeSegment: React.FC = ({ - snippet, - environment, - setEnvironment, - isInstallCommand, - hideTabs, - onlyTabs, -}) => { - const activeEnvironment: CodeEnvironment = useMemo(() => { - return ( - snippet[environment] ? environment : Object.keys(snippet)[0] - ) as CodeEnvironment; - }, [environment, snippet]); - - const activeSnippet = useMemo(() => { - return snippet[activeEnvironment]; - }, [activeEnvironment, snippet]); - - const lines = useMemo( - () => (activeSnippet ? activeSnippet.split("\n") : []), - [activeSnippet], - ); - - const code = lines.join("\n").trim(); - - const environments = Environments.filter( - (env) => - Object.keys(snippet).includes(env.environment) && - snippet[env.environment], - ); - - return ( -
- {!hideTabs && ( - ({ - label: env.title, - onClick: () => setEnvironment(env.environment), - isActive: activeEnvironment === env.environment, - name: env.title, - isEnabled: true, - }))} - tabClassName="text-sm gap-2 !text-sm" - tabIconClassName="size-4" - tabContainerClassName="px-3 pt-1.5 gap-0.5" - hideBottomLine={!!onlyTabs} - /> - )} - - {onlyTabs ? null : ( - <> - - - )} -
- ); -}; diff --git a/apps/dashboard/src/@/components/blocks/code/code-segment.client.tsx b/apps/dashboard/src/@/components/blocks/code/code-segment.client.tsx new file mode 100644 index 00000000000..4ff5dd36ccd --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/code/code-segment.client.tsx @@ -0,0 +1,154 @@ +"use client"; + +import type React from "react"; +import { type Dispatch, type SetStateAction, useMemo } from "react"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { TabButtons } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +export type CodeEnvironment = + | "api" + | "javascript" + | "typescript" + | "react" + | "react-native" + | "dotnet" + | "unity" + | "curl"; + +type SupportedEnvironment = { + environment: CodeEnvironment; + title: string; +}; + +type CodeSnippet = Partial>; + +const Environments: SupportedEnvironment[] = [ + { + environment: "api", + title: "API", + }, + { + environment: "javascript", + title: "JavaScript", + }, + { + environment: "typescript", + title: "TypeScript", + }, + { + environment: "react", + title: "React", + }, + { + environment: "react-native", + title: "React Native", + }, + { + environment: "dotnet", + title: ".NET", + }, + { + environment: "unity", + title: "Unity", + }, + { + environment: "curl", + title: "cURL", + }, +]; + +interface CodeSegmentProps { + snippet: CodeSnippet; + environment: CodeEnvironment; + setEnvironment: + | Dispatch> + | ((language: CodeEnvironment) => void); + isInstallCommand?: boolean; + hideTabs?: boolean; + onlyTabs?: boolean; + codeContainerClassName?: string; +} + +export const CodeSegment: React.FC = ({ + snippet, + environment, + setEnvironment, + isInstallCommand, + hideTabs, + onlyTabs, + codeContainerClassName, +}) => { + const activeEnvironment: CodeEnvironment = useMemo(() => { + return ( + snippet[environment] ? environment : Object.keys(snippet)[0] + ) as CodeEnvironment; + }, [environment, snippet]); + + const activeSnippet = useMemo(() => { + return snippet[activeEnvironment]; + }, [activeEnvironment, snippet]); + + const lines = useMemo( + () => (activeSnippet ? activeSnippet.split("\n") : []), + [activeSnippet], + ); + + const code = lines.join("\n").trim(); + + const environments = Environments.filter( + (env) => + Object.keys(snippet).includes(env.environment) && + snippet[env.environment], + ); + + return ( +
+ {!hideTabs && ( + ({ + isActive: activeEnvironment === env.environment, + label: env.title, + name: env.title, + onClick: () => setEnvironment(env.environment), + }))} + /> + )} + + {onlyTabs ? null : ( + + )} +
+ ); +}; diff --git a/apps/dashboard/src/@/components/blocks/code-segment.stories.tsx b/apps/dashboard/src/@/components/blocks/code/code-segment.stories.tsx similarity index 91% rename from apps/dashboard/src/@/components/blocks/code-segment.stories.tsx rename to apps/dashboard/src/@/components/blocks/code/code-segment.stories.tsx index 7b2c0999073..bd28ca1ca34 100644 --- a/apps/dashboard/src/@/components/blocks/code-segment.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/code/code-segment.stories.tsx @@ -1,3 +1,5 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; import { Select, SelectContent, @@ -6,35 +8,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; -import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import { BadgeContainer } from "@/storybook/utils"; import { type CodeEnvironment, CodeSegment } from "./code-segment.client"; const meta = { - title: "blocks/CodeSegment", component: Story, parameters: { nextjs: { appDirectory: true, }, }, + title: "blocks/CodeSegment", } satisfies Meta; export default meta; type Story = StoryObj; -export const Desktop: Story = { +export const Variants: Story = { args: {}, }; -export const Mobile: Story = { - args: {}, - parameters: { - viewport: mobileViewport("iphone14"), - }, -}; - type Mode = "default" | "no-tabs" | "only-tabs"; function Story() { @@ -44,10 +37,10 @@ function Story() { return (
- +
); @@ -99,23 +92,23 @@ function Variant(props: { return ( ); diff --git a/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx b/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx new file mode 100644 index 00000000000..b72009b2743 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx @@ -0,0 +1,45 @@ +"use client"; +import { ArrowDownToLineIcon, FileTextIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { handleDownload } from "../download-file-button"; + +export function DownloadableCode(props: { + code: string; + lang: "csv" | "json"; + fileNameWithExtension: string; +}) { + return ( +
+

+ + + {props.fileNameWithExtension} + + +

+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/color-mode-toggle.tsx b/apps/dashboard/src/@/components/blocks/color-mode-toggle.tsx new file mode 100644 index 00000000000..c0313bff257 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/color-mode-toggle.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { MoonIcon, SunIcon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { ClientOnly } from "@/components/blocks/client-only"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "../ui/skeleton"; + +export function ToggleThemeButton(props: { className?: string }) { + const { setTheme, theme } = useTheme(); + + return ( + } + > + + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/dismissible-alert.tsx b/apps/dashboard/src/@/components/blocks/dismissible-alert.tsx new file mode 100644 index 00000000000..85f082af1ae --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/dismissible-alert.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { XIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; + +export function DismissibleAlert( + props: { + title: React.ReactNode; + header?: React.ReactNode; + className?: string; + description: React.ReactNode; + children?: React.ReactNode; + } & ( + | { + preserveState: true; + localStorageId: string; + } + | { + preserveState: false; + } + ), +) { + if (props.preserveState) { + return ; + } + + return ; +} + +function DismissibleAlertWithLocalStorage(props: { + title: React.ReactNode; + header?: React.ReactNode; + description: React.ReactNode; + children?: React.ReactNode; + localStorageId: string; +}) { + const [isVisible, setIsVisible] = useLocalStorage( + props.localStorageId, + true, + false, + ); + + if (!isVisible) return null; + + return setIsVisible(false)} />; +} + +function DismissibleAlertWithoutLocalStorage(props: { + title: React.ReactNode; + description: React.ReactNode; + children?: React.ReactNode; +}) { + const [isVisible, setIsVisible] = useState(true); + + if (!isVisible) return null; + + return setIsVisible(false)} />; +} + +function AlertUI(props: { + title: React.ReactNode; + header?: React.ReactNode; + description: React.ReactNode; + children?: React.ReactNode; + className?: string; + onClose: () => void; +}) { + return ( +
+
+ +
+ {props.header} +

{props.title}

+
+ {props.description} +
+ {props.children} +
+
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/distribution-chart.tsx b/apps/dashboard/src/@/components/blocks/distribution-chart.tsx new file mode 100644 index 00000000000..b34094aadda --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/distribution-chart.tsx @@ -0,0 +1,93 @@ +import { cn } from "@/lib/utils"; + +export type Segment = { + label: string; + percent: number; + value: string; + color: string; +}; + +type DistributionBarChartProps = { + segments: Segment[]; + title?: string; + titleClassName?: string; + barClassName?: string; +}; + +export function DistributionBarChart(props: DistributionBarChartProps) { + const totalPercentage = props.segments.reduce( + (sum, segment) => sum + segment.percent, + 0, + ); + + const invalidTotalPercentage = totalPercentage !== 100; + + return ( +
+ {props.title && ( +
+

+ {props.title} +

+
+ Total: {totalPercentage}% +
+
+ )} + + {/* Bar */} +
+ {props.segments.map((segment) => { + return ( +
0 && "border-r-2 border-background", + )} + key={segment.label} + style={{ + backgroundColor: segment.color, + width: `${segment.percent}%`, + }} + /> + ); + })} +
+ + {/* Legends */} +
+ {props.segments.map((segment) => { + return ( +
+
+

100 || segment.percent < 0) && + "text-destructive-text", + )} + > + {segment.label}: {segment.value} +

+
+ ); + })} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/download-file-button.tsx b/apps/dashboard/src/@/components/blocks/download-file-button.tsx new file mode 100644 index 00000000000..df9e55c4a05 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/download-file-button.tsx @@ -0,0 +1,47 @@ +import { ArrowDownToLineIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function handleDownload(params: { + fileContent: string; + fileNameWithExtension: string; + fileFormat: "text/csv" | "application/json"; +}) { + const link = document.createElement("a"); + const blob = new Blob([params.fileContent], { + type: params.fileFormat, + }); + const objectURL = URL.createObjectURL(blob); + link.href = objectURL; + link.download = params.fileNameWithExtension; + link.click(); + URL.revokeObjectURL(objectURL); +} + +export function DownloadFileButton(props: { + fileContent: string; + fileNameWithExtension: string; + fileFormat: "text/csv" | "application/json"; + label: string; + variant?: "default" | "outline"; + className?: string; + iconClassName?: string; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.stories.tsx b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.stories.tsx new file mode 100644 index 00000000000..ae0ebd29c7f --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { DropZone } from "./drop-zone"; + +const meta = { + component: DropZone, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + title: "blocks/DropZone", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + accept: undefined, + description: "This is a description for drop zone", + isError: false, + onDrop: () => {}, + resetButton: undefined, + title: "This is a title", + }, +}; + +export const ErrorState: Story = { + args: { + accept: undefined, + description: "This is a description", + isError: true, + onDrop: () => {}, + resetButton: undefined, + title: "this is title", + }, +}; + +export const ErrorStateWithResetButton: Story = { + args: { + accept: undefined, + description: "This is a description", + isError: true, + onDrop: () => {}, + resetButton: { + label: "Remove Files", + onClick: () => {}, + }, + title: "this is title", + }, +}; diff --git a/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx new file mode 100644 index 00000000000..054d60f41a2 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/drop-zone/drop-zone.tsx @@ -0,0 +1,81 @@ +import { UploadIcon, XIcon } from "lucide-react"; +import { useDropzone } from "react-dropzone"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function DropZone(props: { + isError: boolean; + onDrop: (acceptedFiles: File[]) => void; + title: string; + description: string; + resetButton: + | { + label: string; + onClick: () => void; + } + | undefined; + className?: string; + accept: string | undefined; +}) { + const { getRootProps, getInputProps } = useDropzone({ + onDrop: props.onDrop, + }); + + const { resetButton } = props; + + return ( +
+ +
+ {!props.isError && ( +
+
+ +
+

+ {props.title} +

+

+ {props.description} +

+
+ )} + + {props.isError && ( +
+
+ +
+

+ {props.title} +

+

+ {props.description} +

+ + {resetButton && ( + + )} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx b/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx index 2642f46882f..8ce65036fe3 100644 --- a/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx +++ b/apps/dashboard/src/@/components/blocks/error-fallbacks/unexpect-value-error-message.tsx @@ -1,6 +1,6 @@ -import { CopyTextButton } from "@/components/ui/CopyTextButton"; -import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; import { useMemo } from "react"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { ScrollShadow } from "@/components/ui/ScrollShadow"; export function UnexpectedValueErrorMessage(props: { value: unknown; @@ -30,11 +30,11 @@ export function UnexpectedValueErrorMessage(props: {
diff --git a/apps/dashboard/src/@/components/blocks/faq-section.tsx b/apps/dashboard/src/@/components/blocks/faq-section.tsx new file mode 100644 index 00000000000..a06315888c6 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/faq-section.tsx @@ -0,0 +1,68 @@ +"use client"; +import { ChevronDownIcon } from "lucide-react"; +import { useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { cn } from "@/lib/utils"; + +export function FaqAccordion(props: { + faqs: Array<{ title: string; description: string }>; +}) { + return ( +
+ {props.faqs.map((faq, faqIndex) => ( + + ))} +
+ ); +} + +function FaqItem(props: { + title: string; + description: string; + className?: string; +}) { + const [isOpen, setIsOpenn] = useState(false); + const contentId = useId(); + return ( + +
+

+ +

+ +

+ {props.description} +

+
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/file-preview.tsx b/apps/dashboard/src/@/components/blocks/file-preview.tsx new file mode 100644 index 00000000000..a28e1697261 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/file-preview.tsx @@ -0,0 +1,72 @@ +import { ImageOffIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { MediaRenderer } from "thirdweb/react"; +import { Img } from "@/components/blocks/Img"; +import { fileToBlobUrl } from "@/lib/file-to-url"; +import { cn } from "@/lib/utils"; + +export function FilePreview(props: { + srcOrFile: File | string | undefined; + fallback?: React.ReactNode; + className?: string; + client: ThirdwebClient; + imgContainerClassName?: string; +}) { + const [objectUrl, setObjectUrl] = useState(""); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (props.srcOrFile instanceof File) { + const url = fileToBlobUrl(props.srcOrFile); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } else if (typeof props.srcOrFile === "string") { + setObjectUrl(props.srcOrFile); + } else { + setObjectUrl(""); + } + }, [props.srcOrFile]); + + // shortcut just for images + const isImage = + props.srcOrFile instanceof File && + props.srcOrFile.type.startsWith("image/"); + + if (!objectUrl) { + return ( +
+ +
+ ); + } + + if (isImage) { + return ( + + ); + } + + return ( + div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative overflow-hidden", + )} + client={props.client} + src={objectUrl} + /> + ); +} diff --git a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx new file mode 100644 index 00000000000..26ce5518be2 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { NavLink } from "@/components/ui/NavLink"; +import { Separator } from "@/components/ui/separator"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +type ShadcnSidebarBaseLink = { + href: string; + label: React.ReactNode; + exactMatch?: boolean; + icon?: React.FC<{ className?: string }>; + isActive?: (pathname: string) => boolean; +}; + +export type ShadcnSidebarLink = + | ShadcnSidebarBaseLink + | { + group: string; + links: ShadcnSidebarLink[]; + } + | { + separator: true; + } + | { + subMenu: Omit; + links: Omit[]; + }; + +export function FullWidthSidebarLayout(props: { + contentSidebarLinks: ShadcnSidebarLink[]; + footerSidebarLinks?: ShadcnSidebarLink[]; + children: React.ReactNode; + className?: string; +}) { + const { contentSidebarLinks, children, footerSidebarLinks } = props; + return ( +
+ {/* left - sidebar */} + + + + + + {footerSidebarLinks && ( + + + + )} + + + + + {/* right - content */} +
+ + +
+ {children} +
+
+
+ ); +} + +function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) { + const activeLink = useActiveShadcnSidebarLink(props.links); + const parentSubNav = findParentSubmenu(props.links, activeLink?.href); + + return ( +
+ + + {parentSubNav && ( + <> + {parentSubNav.subMenu.label} + + + )} + {activeLink && {activeLink.label}} +
+ ); +} + +function useActiveShadcnSidebarLink(links: ShadcnSidebarLink[]) { + const pathname = usePathname(); + + const activeLink = useMemo(() => { + function isActive(link: ShadcnSidebarBaseLink) { + if (link.exactMatch) { + return link.href === pathname; + } + return pathname?.startsWith(link.href); + } + + function walk( + navLinks: ShadcnSidebarLink[], + ): ShadcnSidebarBaseLink | undefined { + for (const link of navLinks) { + if ("subMenu" in link) { + for (const subLink of link.links) { + if (isActive(subLink)) { + return subLink; + } + } + } else if ("href" in link) { + if (isActive(link)) { + return link; + } + } + + if ("links" in link && !("subMenu" in link)) { + const nested = walk(link.links); + if (nested) { + return nested; + } + } + } + + return undefined; + } + + return walk(links); + }, [links, pathname]); + + return activeLink; +} + +function findParentSubmenu( + links: ShadcnSidebarLink[], + activeHref: string | undefined, +): Extract | undefined { + if (!activeHref) { + return undefined; + } + + for (const link of links) { + if ("subMenu" in link) { + if (link.links.some((subLink) => subLink.href === activeHref)) { + return link; + } + } + + if ("links" in link && !("subMenu" in link)) { + const nested = findParentSubmenu(link.links, activeHref); + if (nested) { + return nested; + } + } + } + + return undefined; +} + +function useIsSubnavActive(links: ShadcnSidebarBaseLink[]) { + const pathname = usePathname(); + + const isSubnavActive = useMemo(() => { + function isActive(link: ShadcnSidebarBaseLink) { + if (link.exactMatch) { + return link.href === pathname; + } + return pathname?.startsWith(link.href); + } + + return links.some(isActive); + }, [links, pathname]); + + return isSubnavActive; +} + +function RenderSidebarGroup(props: { + sidebarLinks: ShadcnSidebarLink[]; + groupName: string; +}) { + return ( + + + {props.groupName} + + + + + + ); +} + +function RenderSidebarSubmenu(props: { + links: ShadcnSidebarBaseLink[]; + subMenu: Omit; +}) { + const sidebar = useSidebar(); + const isSubnavActive = useIsSubnavActive(props.links); + return ( + + + + + + + {props.subMenu.icon && ( + + )} + {props.subMenu.label} + + + + + + + {props.links.map((link) => { + return ( + + { + sidebar.setOpenMobile(false); + }} + > + {link.icon && } + {link.label} + + + ); + })} + + + + + + + ); +} + +function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) { + const sidebar = useSidebar(); + return ( + + {props.links.map((link, idx) => { + // link + if ("href" in link) { + return ( + + + { + sidebar.setOpenMobile(false); + }} + > + {link.icon && } + {link.label} + + + + ); + } + + // separator + if ("separator" in link) { + return ( + + ); + } + + // subnav + if ("subMenu" in link) { + return ( + + ); + } + + // group + return ( + + ); + })} + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/fund-wallets-modal/fund-wallets-modal.stories.tsx b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/fund-wallets-modal.stories.tsx new file mode 100644 index 00000000000..d44624544c0 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/fund-wallets-modal.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { ConnectButton, ThirdwebProvider } from "thirdweb/react"; +import { Button } from "@/components/ui/button"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { FundWalletModal } from "./index"; + +const meta: Meta = { + title: "Blocks/FundWalletModal", + component: Variant, + decorators: [ + (Story) => ( + +
+ +
+ +
+ + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Test: Story = { + args: {}, +}; +function Variant() { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/fund-wallets-modal/index.tsx b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/index.tsx new file mode 100644 index 00000000000..730557f9725 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/fund-wallets-modal/index.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import type { ThirdwebClient } from "thirdweb"; +import { defineChain } from "thirdweb/chains"; +import { CheckoutWidget, useActiveWalletChain } from "thirdweb/react"; +import { z } from "zod"; +import { + reportFundWalletFailed, + reportFundWalletSuccessful, +} from "@/analytics/report"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { TokenSelector } from "@/components/blocks/TokenSelector"; +import { Button } from "@/components/ui/button"; +import { DecimalInput } from "@/components/ui/decimal-input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { getSDKTheme } from "@/utils/sdk-component-theme"; +import { WalletAddress } from "../wallet-address"; + +const formSchema = z.object({ + chainId: z.number({ + required_error: "Chain is required", + }), + token: z.object( + { + chainId: z.number(), + address: z.string(), + symbol: z.string(), + name: z.string(), + decimals: z.number(), + }, + { + required_error: "Token is required", + }, + ), + amount: z + .string() + .min(1, "Amount is required") + .refine((value) => { + const num = Number(value); + return !Number.isNaN(num) && num > 0; + }, "Amount must be greater than 0"), +}); + +type FormData = z.infer; + +type FundWalletModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + recipientAddress: string; + client: ThirdwebClient; + defaultChainId?: number; + checkoutWidgetTitle?: string; +}; + +export function FundWalletModal(props: FundWalletModalProps) { + return ( + + + + + + ); +} + +function FundWalletModalContent(props: FundWalletModalProps) { + const [step, setStep] = useState<"form" | "checkout">("form"); + const activeChain = useActiveWalletChain(); + const { theme } = useTheme(); + + const form = useForm({ + defaultValues: { + chainId: props.defaultChainId || activeChain?.id || 1, + token: undefined, + amount: "0.1", + }, + mode: "onChange", + resolver: zodResolver(formSchema), + }); + + const selectedChainId = form.watch("chainId"); + + return ( +
+ {step === "form" ? ( +
+ { + setStep("checkout"); + })} + > + + {props.title} + {props.description} + + +
+
+

Recipient

+ +
+ + ( + + Chain + + { + field.onChange(token); + form.resetField("token", { + defaultValue: undefined, + }); + }} + client={props.client} + placeholder="Select a chain" + disableDeprecated + disableTestnets + disableChainId + /> + + + + )} + /> + + { + return ( + + Token + + + + + + ); + }} + /> + + ( + + Amount + +
+ + {form.watch("token") && ( +
+ {form.watch("token").symbol} +
+ )} +
+
+ +
+ )} + /> +
+ +
+ + +
+
+ + ) : ( +
+ + {props.title} + {props.description} + + +
+ { + reportFundWalletSuccessful(); + }} + onError={(error) => { + reportFundWalletFailed({ + errorMessage: error.message, + }); + }} + /> +
+ +
+ + + +
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx new file mode 100644 index 00000000000..bae0ec0865e --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx @@ -0,0 +1,23 @@ +import { GridPattern } from "@/components/ui/background-patterns"; + +export function GridPatternEmbedContainer(props: { + children: React.ReactNode; +}) { + return ( +
+ +
{props.children}
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/markdown-renderer.tsx b/apps/dashboard/src/@/components/blocks/markdown-renderer.tsx new file mode 100644 index 00000000000..8d10d475af4 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/markdown-renderer.tsx @@ -0,0 +1 @@ +export { MarkdownRenderer } from "@workspace/ui/components/markdown-renderer"; diff --git a/apps/dashboard/src/@/components/blocks/media-renderer.tsx b/apps/dashboard/src/@/components/blocks/media-renderer.tsx new file mode 100644 index 00000000000..a7148606bd5 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/media-renderer.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { MediaRenderer, type MediaRendererProps } from "thirdweb/react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +export function CustomMediaRenderer(props: MediaRendererProps) { + const [loadedSrc, setLoadedSrc] = useState(undefined); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (loadedSrc && loadedSrc !== props.src) { + setLoadedSrc(undefined); + } + }, [loadedSrc, props.src]); + + return ( +
{ + if (props.src) { + setLoadedSrc(props.src); + } + }} + > + {!loadedSrc && } + div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative z-10", + )} + /> +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx b/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx index 7b9636a04b1..e39aea925b2 100644 --- a/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/multi-select.stories.tsx @@ -1,54 +1,47 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/nextjs"; import { useMemo, useState } from "react"; -import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import { BadgeContainer } from "@/storybook/utils"; import { MultiSelect } from "./multi-select"; const meta = { - title: "blocks/MultiSelect", component: Story, parameters: { nextjs: { appDirectory: true, }, }, + title: "blocks/MultiSelect", } satisfies Meta; export default meta; type Story = StoryObj; -export const Desktop: Story = { - args: {}, -}; - -export const Mobile: Story = { +export const Variants: Story = { args: {}, - parameters: { - viewport: mobileViewport("iphone14"), - }, }; function createList(len: number) { return Array.from({ length: len }, (_, i) => ({ - value: `${i}`, label: `Item ${i}`, + value: `${i}`, })); } function Story() { return ( -
- - +
+ +
); @@ -66,13 +59,13 @@ function VariantTest(props: { return ( { setValues(values); }} + options={list} placeholder="Select items" - maxCount={props.maxCount} + selectedValues={values} /> ); diff --git a/apps/dashboard/src/@/components/blocks/multi-select.tsx b/apps/dashboard/src/@/components/blocks/multi-select.tsx index 3b3249ee9b2..3f2190b7196 100644 --- a/apps/dashboard/src/@/components/blocks/multi-select.tsx +++ b/apps/dashboard/src/@/components/blocks/multi-select.tsx @@ -1,26 +1,19 @@ -/* eslint-disable no-restricted-syntax */ +/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */ "use client"; +import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react"; +import { forwardRef, useCallback, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { ScrollShadow } from "@/components/ui/ScrollShadow"; import { Separator } from "@/components/ui/separator"; +import { useShowMore } from "@/hooks/useShowMore"; import { cn } from "@/lib/utils"; -import { CheckIcon, ChevronDown, SearchIcon, XIcon } from "lucide-react"; -import { - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useShowMore } from "../../lib/useShowMore"; -import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow"; -import { Input } from "../ui/input"; interface MultiSelectProps extends React.ButtonHTMLAttributes { @@ -47,7 +40,13 @@ interface MultiSelectProps searchTerm: string, ) => boolean; + popoverContentClassName?: string; + customTrigger?: React.ReactNode; renderOption?: (option: { value: string; label: string }) => React.ReactNode; + align?: "center" | "start" | "end"; + side?: "left" | "right" | "top" | "bottom"; + showSelectedValuesInModal?: boolean; + customSearchInput?: React.ReactNode; } export const MultiSelect = forwardRef( @@ -62,6 +61,10 @@ export const MultiSelect = forwardRef( overrideSearchFn, renderOption, searchPlaceholder, + popoverContentClassName, + showSelectedValuesInModal = false, + customSearchInput, + customTrigger, ...props }, ref, @@ -141,138 +144,137 @@ export const MultiSelect = forwardRef( // scroll to top when options change const popoverElRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - const scrollContainer = - popoverElRef.current?.querySelector("[data-scrollable]"); - if (scrollContainer) { - scrollContainer.scrollTo({ - top: 0, - }); - } - }, [searchValue]); + + // Filter out customTrigger from props to avoid passing it to Button + const buttonProps = Object.fromEntries( + Object.entries(props).filter(([key]) => key !== "customTrigger"), + ) as React.ButtonHTMLAttributes; return ( - + - + ) : ( +
+ + {placeholder} + + +
+ )} + + )}
setIsPopoverOpen(false)} + ref={popoverElRef} + side={props.side} + sideOffset={10} style={{ + height: + "calc(var(--radix-popover-content-available-height) - 40px)", width: "var(--radix-popover-trigger-width)", - maxHeight: "var(--radix-popover-content-available-height)", }} - ref={popoverElRef} > -
- {/* Search */} -
+ {/* Search */} +
+ {customSearchInput ? ( + customSearchInput + ) : ( setSearchValue(e.target.value)} className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0" + onChange={(e) => { + setSearchValue(e.target.value); + const scrollContainer = + popoverElRef.current?.querySelector("[data-scrollable]"); + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + }); + } + }} + // do not focus on the input when the popover opens to avoid opening the keyboard onKeyDown={handleInputKeyDown} + placeholder={searchPlaceholder || "Search"} + tabIndex={-1} + value={searchValue} /> - + )} + +
+ + {optionsToShow.length === 0 && ( +
+ No results found
+ )} + {optionsToShow.length > 0 && ( {/* List */}
- {optionsToShow.length === 0 && ( -
- No results found -
- )} - {optionsToShow.map((option, i) => { const isSelected = selectedValues.includes(option.value); return (
+ )} + + {showSelectedValuesInModal && selectedValues.length > 0 && ( +
+ +
+ )} ); }, ); -function ClosableBadge(props: { - label: string; - onClose: () => void; -}) { +function ClosableBadge(props: { label: string; onClose: () => void }) { return ( {props.label} @@ -319,3 +331,44 @@ function ClosableBadge(props: { } MultiSelect.displayName = "MultiSelect"; + +function SelectedChainsBadges(props: { + selectedValues: string[]; + options: { + label: string; + value: string; + }[]; + maxCount: number; + onClose: () => void; + toggleOption: (value: string) => void; + clearExtraOptions: () => void; +}) { + const { selectedValues, options, maxCount, toggleOption, clearExtraOptions } = + props; + return ( +
+ {selectedValues.slice(0, maxCount).map((value) => { + const option = options.find((o) => o.value === value); + if (!option) { + return null; + } + + return ( + toggleOption(value)} + /> + ); + })} + + {/* +X more */} + {selectedValues.length > maxCount && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx new file mode 100644 index 00000000000..639fa9b7413 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { MultiStepStatus } from "./multi-step-status"; + +const meta = { + component: MultiStepStatus, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + title: "Blocks/MultiStepStatus", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AllStates: Story = { + args: { + onRetry: () => {}, + steps: [ + { + id: "connect-wallet", + label: "Connect Wallet", + status: { type: "completed" }, + }, + { + id: "sign-message", + label: "Sign Message", + status: { type: "pending" }, + }, + { + id: "approve-transaction", + label: "Approve Transaction", + status: { message: "This is an error message", type: "error" }, + }, + { + id: "confirm-transaction", + label: "Confirm Transaction", + status: { type: "idle" }, + }, + { + id: "finalize", + label: "Finalize", + status: { type: "idle" }, + }, + ], + }, +}; diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx new file mode 100644 index 00000000000..873a8760e62 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { + AlertCircleIcon, + CircleCheckIcon, + CircleIcon, + RefreshCwIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { Spinner } from "@/components/ui/Spinner"; + +export type MultiStepState = { + id: T; + status: + | { + type: "idle" | "pending" | "completed"; + } + | { + type: "error"; + message: string; + }; + label: string; + description?: string; +}; + +export function MultiStepStatus(props: { + steps: MultiStepState[]; + onRetry: (step: MultiStepState) => void; + renderError?: ( + step: MultiStepState, + errorMessage: string, + ) => React.ReactNode; +}) { + return ( + +
+ {props.steps.map((step) => ( +
+ {step.status.type === "completed" ? ( + + ) : step.status.type === "pending" ? ( + + ) : step.status.type === "error" ? ( + + ) : ( + + )} +
+

+ {step.label} +

+ + {/* show description when this step is active */} + {(step.status.type === "pending" || + step.status.type === "error") && + step.description && ( +

+ {step.description} +

+ )} + + {step.status.type === "error" + ? props.renderError?.(step, step.status.message) || ( +
+

+ {step.status.message} +

+ +
+ ) + : null} +
+
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/nft-media.tsx b/apps/dashboard/src/@/components/blocks/nft-media.tsx new file mode 100644 index 00000000000..26752628f52 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/nft-media.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { ImageIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; +import { MediaRenderer } from "thirdweb/react"; +import { cn } from "@/lib/utils"; + +export const NFTMediaWithEmptyState: React.FC<{ + className?: string; + width?: string; + height?: string; + metadata: { + image?: string | null; + animation_url?: string | null; + name?: string | number | null; + }; + requireInteraction?: boolean; + controls?: boolean; + client: ThirdwebClient; +}> = (props) => { + // No media + if (!(props.metadata.image || props.metadata.animation_url)) { + return ( +
+
+ + No Media +
+
+ ); + } + return ( +
+ +
+ ); +}; diff --git a/apps/dashboard/src/@/components/blocks/pagination-buttons.stories.tsx b/apps/dashboard/src/@/components/blocks/pagination-buttons.stories.tsx new file mode 100644 index 00000000000..27c7cf32719 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/pagination-buttons.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { BadgeContainer } from "@/storybook/utils"; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "blocks/PaginationButtons", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story() { + return ( +
+ + + + + + +
+ ); +} + +function Variant(props: { label: string; totalPages: number }) { + const [activePage, setActivePage] = useState(1); + return ( + +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/pagination-buttons.tsx b/apps/dashboard/src/@/components/blocks/pagination-buttons.tsx new file mode 100644 index 00000000000..d4e98b76ec8 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/pagination-buttons.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { ArrowUpRightIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { cn } from "@/lib/utils"; + +export const PaginationButtons = (props: { + activePage: number; + totalPages: number; + onPageClick: (page: number) => void; + className?: string; +}) => { + const { activePage, totalPages, onPageClick: setPage } = props; + const [inputHasError, setInputHasError] = useState(false); + const [pageNumberInput, setPageNumberInput] = useState(""); + + if (totalPages === 1) { + return null; + } + + function handlePageInputSubmit() { + const page = Number(pageNumberInput); + + setInputHasError(false); + if (Number.isInteger(page) && page > 0 && page <= totalPages) { + setPage(page); + setPageNumberInput(""); + } else { + setInputHasError(true); + } + } + + // if only two pages, show "prev" and "next" + if (totalPages === 2) { + return ( + + + + { + setPage(activePage - 1); + }} + /> + + + { + setPage(activePage + 1); + }} + /> + + + + ); + } + + // just render all the page buttons directly + if (totalPages <= 6) { + const pages = [...Array(totalPages)].map((_, i) => i + 1); + return ( + + + {pages.map((page) => ( + + { + setPage(page); + }} + > + {page} + + + ))} + + + ); + } + + return ( + + + + { + setPage(activePage - 1); + }} + /> + + + {/* First page + ... */} + {activePage - 3 > 0 && ( + <> + + { + setPage(1); + }} + > + 1 + + + + + + + + )} + + {activePage - 1 > 0 && ( + + { + setPage(activePage - 1); + }} + > + {activePage - 1} + + + )} + + + + {activePage} + + + + {activePage + 1 <= totalPages && ( + + { + setPage(activePage + 1); + }} + > + {activePage + 1} + + + )} + + {/* ... + Last page */} + {activePage + 3 <= totalPages && ( + <> + + + + + + { + setPage(totalPages); + }} + > + {totalPages} + + + + )} + + + { + setPage(activePage + 1); + }} + /> + + +
+ { + setInputHasError(false); + setPageNumberInput(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handlePageInputSubmit(); + } + }} + placeholder="Page" + type="number" + value={pageNumberInput} + /> + +
+
+
+ ); +}; diff --git a/apps/dashboard/src/@/components/blocks/pricing-card.tsx b/apps/dashboard/src/@/components/blocks/pricing-card.tsx deleted file mode 100644 index 9884b665ab8..00000000000 --- a/apps/dashboard/src/@/components/blocks/pricing-card.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import type { Team } from "@/api/team"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { ToolTipLabel } from "@/components/ui/tooltip"; -import { TrackedLinkTW } from "@/components/ui/tracked-link"; -import { cn } from "@/lib/utils"; -import { CheckIcon, CircleDollarSignIcon } from "lucide-react"; -import type React from "react"; -import { TEAM_PLANS } from "utils/pricing"; -import { remainingDays } from "../../../utils/date-utils"; -import type { RedirectBillingCheckoutAction } from "../../actions/billing"; -import { CheckoutButton } from "../billing"; - -type ButtonProps = React.ComponentProps; - -const PRO_CONTACT_US_URL = - "https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro"; - -type PricingCardProps = { - teamSlug: string; - billingPlan: Exclude; - cta?: { - hint?: string; - title: string; - tracking: { - category: string; - label?: string; - }; - variant?: ButtonProps["variant"]; - }; - ctaHint?: string; - highlighted?: boolean; - current?: boolean; - canTrialGrowth?: boolean; - activeTrialEndsAt?: string; - redirectPath: string; - redirectToCheckout: RedirectBillingCheckoutAction; -}; - -export const PricingCard: React.FC = ({ - teamSlug, - billingPlan, - cta, - highlighted = false, - current = false, - canTrialGrowth = false, - activeTrialEndsAt, - redirectPath, - redirectToCheckout, -}) => { - const plan = TEAM_PLANS[billingPlan]; - const isCustomPrice = typeof plan.price === "string"; - - const remainingTrialDays = - (activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0; - - return ( -
-
- {/* Title + Desc */} -
-
-

- {plan.title} -

- {current && Current plan} -
-

- {plan.description} -

-
- - {/* Price */} -
-
- - {isCustomPrice ? ( - plan.price - ) : canTrialGrowth ? ( - <> - - ${plan.price} - {" "} - $0 - - ) : ( - `$${plan.price}` - )} - - - {!isCustomPrice && ( - / month - )} -
- - {remainingTrialDays > 0 && ( -

- Your free trial will{" "} - {remainingTrialDays > 1 - ? `end in ${remainingTrialDays} days.` - : "end today."} -

- )} -
-
- -
- {plan.subTitle && ( -

{plan.subTitle}

- )} - - {plan.features.map((f) => ( - - ))} -
- - {cta && ( -
- {billingPlan !== "pro" ? ( - - {cta.title} - - ) : ( - - )} - - {cta.hint && ( -

- {cta.hint} -

- )} -
- )} -
- ); -}; - -type FeatureItemProps = { - text: string | string[]; -}; - -function FeatureItem({ text }: FeatureItemProps) { - const titleStr = Array.isArray(text) ? text[0] : text; - - return ( -
- - {Array.isArray(text) ? ( -
-

- {titleStr}{" "} - {text[1]} -

- - - -
- ) : ( -

{titleStr}

- )} -
- ); -} diff --git a/apps/dashboard/src/@/components/blocks/project-page/header/link-group.tsx b/apps/dashboard/src/@/components/blocks/project-page/header/link-group.tsx new file mode 100644 index 00000000000..bbddd8d5fc9 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/project-page/header/link-group.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { + BookTextIcon, + BoxIcon, + ChevronDownIcon, + CodeIcon, + WebhookIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +type LinkType = "api" | "docs" | "playground" | "webhooks"; + +const linkTypeToLabel: Record = { + api: "API Reference", + docs: "Documentation", + playground: "Playground", + webhooks: "Webhooks", +}; + +const linkTypeToOrder: Record = { + docs: 0, + playground: 1, + api: 3, + webhooks: 4, +}; + +const linkTypeToIcon: Record> = { + api: CodeIcon, + docs: BookTextIcon, + playground: BoxIcon, + webhooks: WebhookIcon, +}; + +function orderLinks(links: ActionLink[]) { + return links.slice().sort((a, b) => { + const aIndex = linkTypeToOrder[a.type]; + const bIndex = linkTypeToOrder[b.type]; + return aIndex - bIndex; + }); +} + +export type ActionLink = { + type: LinkType; + href: string; +}; + +export function LinkGroup(props: { links: ActionLink[] }) { + const orderedLinks = useMemo(() => orderLinks(props.links), [props.links]); + + if (orderedLinks.length === 1 && orderedLinks[0]) { + const link = orderedLinks[0]; + const Icon = linkTypeToIcon[link.type]; + return ( + + + + ); + } + + return ( + + + + + + {orderedLinks.map((link) => { + const isExternal = link.href.startsWith("http"); + const Icon = linkTypeToIcon[link.type]; + return ( + + + + {linkTypeToLabel[link.type]} + + + ); + })} + + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/project-page/project-page-footer.tsx b/apps/dashboard/src/@/components/blocks/project-page/project-page-footer.tsx new file mode 100644 index 00000000000..78777eb397c --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/project-page/project-page-footer.tsx @@ -0,0 +1,14 @@ +import { + type FooterCardProps, + FooterLinksSection, +} from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/footer/FooterLinksSection"; + +export type ProjectPageFooterProps = FooterCardProps; + +export function ProjectPageFooter(props: ProjectPageFooterProps) { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/project-page/project-page-header.tsx b/apps/dashboard/src/@/components/blocks/project-page/project-page-header.tsx new file mode 100644 index 00000000000..f7877cfbca3 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/project-page/project-page-header.tsx @@ -0,0 +1,155 @@ +import { Button } from "@workspace/ui/components/button"; +import { ArrowUpRightIcon, Settings2Icon } from "lucide-react"; +import Link from "next/link"; +import type { ThirdwebClient } from "thirdweb"; +import { cn } from "@/lib/utils"; +import { type ActionLink, LinkGroup } from "./header/link-group"; + +type Action = + | ({ + label: string; + href: string; + } & ( + | { + external: true; + icon?: never; + } + | { + icon?: React.ReactNode; + external?: false; + } + )) + | { + component: React.ReactNode; + }; + +function Action(props: { action: Action; variant?: "default" | "outline" }) { + const action = props.action; + + return "component" in action ? ( + action.component + ) : ( + + ); +} + +export type ProjectPageHeaderProps = { + client: ThirdwebClient; + title: string; + description?: React.ReactNode; + imageUrl?: string | null; + icon: React.FC<{ className?: string }>; + isProjectIcon?: boolean; + actions: { + primary?: Action; + secondary?: Action; + } | null; + + links?: ActionLink[]; + settings?: { + href: string; + }; + + className?: string; + // TODO: add task card component + task?: never; +}; + +export function ProjectPageHeader(props: ProjectPageHeaderProps) { + return ( +
+ {/* top row */} +
+ {/* left - icon */} +
+ {props.isProjectIcon ? ( + + ) : ( +
+ +
+ )} +
+ + {/* right - buttons */} +
+
+ {props.links && props.links.length > 0 && ( + + )} + + {props.settings && ( + + + + )} +
+ + {/* hide on mobile */} + {props.actions && ( +
+ {props.actions.secondary && ( + + )} + + {props.actions.primary && ( + + )} +
+ )} +
+
+ +
+ {/* mid row */} +
+

+ {props.title} +

+ {/* description */} +

+ {props.description} +

+
+ + {/* bottom row - hidden on desktop */} + {props.actions && ( +
+ {props.actions?.primary && ( + + )} + + {props.actions?.secondary && ( + + )} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/project-page/project-page.tsx b/apps/dashboard/src/@/components/blocks/project-page/project-page.tsx new file mode 100644 index 00000000000..fa2f04083f0 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/project-page/project-page.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils"; +import { type TabPathLink, TabPathLinks } from "../../ui/tabs"; +import { + ProjectPageFooter, + type ProjectPageFooterProps, +} from "./project-page-footer"; +import { + ProjectPageHeader, + type ProjectPageHeaderProps, +} from "./project-page-header"; + +type ProjectPageProps = { + header: ProjectPageHeaderProps; + footer?: ProjectPageFooterProps; + tabs?: TabPathLink[]; + containerClassName?: string; +}; + +export function ProjectPage(props: React.PropsWithChildren) { + return ( +
+
+ 0 ? "pt-8 pb-6" : "py-8", + )} + /> + {props.tabs && ( + + )} +
+ +
+ {props.children} +
+
+ {props.footer && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.stories.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.stories.tsx index 980a717e6ea..d9deefe53ec 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.stories.tsx @@ -1,48 +1,41 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/nextjs"; import { useMemo, useState } from "react"; -import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import { BadgeContainer } from "@/storybook/utils"; import { SelectWithSearch } from "./select-with-search"; const meta = { - title: "blocks/SelectWithSearch", component: Story, parameters: { nextjs: { appDirectory: true, }, }, + title: "blocks/SelectWithSearch", } satisfies Meta; export default meta; type Story = StoryObj; -export const Desktop: Story = { - args: {}, -}; - -export const Mobile: Story = { +export const Variants: Story = { args: {}, - parameters: { - viewport: mobileViewport("iphone14"), - }, }; function createList(len: number) { return Array.from({ length: len }, (_, i) => ({ - value: `${i}`, label: `Item ${i}`, + value: `${i}`, })); } function Story() { return ( -
- - +
+ +
); @@ -59,10 +52,10 @@ function VariantTest(props: { return ( ); diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx index fbeebfdb80b..e19026d9f43 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx @@ -1,18 +1,18 @@ -/* eslint-disable no-restricted-syntax */ +/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */ "use client"; +import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; +import React, { useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { ScrollShadow } from "@/components/ui/ScrollShadow"; +import { useShowMore } from "@/hooks/useShowMore"; import { cn } from "@/lib/utils"; -import { CheckIcon, ChevronDown, SearchIcon } from "lucide-react"; -import React, { useRef, useMemo, useEffect } from "react"; -import { useShowMore } from "../../lib/useShowMore"; -import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow"; -import { Input } from "../ui/input"; interface SelectWithSearchProps extends React.ButtonHTMLAttributes { @@ -22,7 +22,7 @@ interface SelectWithSearchProps }[]; value: string | undefined; onValueChange: (value: string) => void; - placeholder: string; + placeholder: string | React.ReactNode; searchPlaceholder?: string; className?: string; overrideSearchFn?: ( @@ -34,6 +34,7 @@ interface SelectWithSearchProps side?: "left" | "right" | "top" | "bottom"; align?: "center" | "start" | "end"; closeOnSelect?: boolean; + showCheck?: boolean; } export const SelectWithSearch = React.forwardRef< @@ -52,6 +53,7 @@ export const SelectWithSearch = React.forwardRef< popoverContentClassName, searchPlaceholder, closeOnSelect, + showCheck = true, ...props }, ref, @@ -99,70 +101,71 @@ export const SelectWithSearch = React.forwardRef< // scroll to top when options change const popoverElRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - const scrollContainer = - popoverElRef.current?.querySelector("[data-scrollable]"); - if (scrollContainer) { - scrollContainer.scrollTo({ - top: 0, - }); - } - }, [searchValue]); return ( - + setIsPopoverOpen(false)} + ref={popoverElRef} side={props.side} sideOffset={10} - onEscapeKeyDown={() => setIsPopoverOpen(false)} style={{ - width: "var(--radix-popover-trigger-width)", maxHeight: "var(--radix-popover-content-available-height)", + width: "var(--radix-popover-trigger-width)", }} - ref={popoverElRef} >
{/* Search */}
{ + setSearchValue(e.target.value); + const scrollContainer = + popoverElRef.current?.querySelector("[data-scrollable]"); + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + }); + } + }} placeholder={searchPlaceholder || "Search"} value={searchValue} - onChange={(e) => setSearchValue(e.target.value)} - className="!h-auto rounded-b-none border-0 border-border border-b py-3 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0" />
{/* List */}
@@ -176,24 +179,26 @@ export const SelectWithSearch = React.forwardRef< const isSelected = value === option.value; return ( + + { + // do not close the hover card when clicking anywhere in the content + e.stopPropagation(); + }} + > +
+
+

Solana Public Key

+ +
+

+ {lessShortenedAddress} +

+
+

+ Solana public key for blockchain transactions. +

+
+
+
+ + ); +} diff --git a/apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx b/apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx new file mode 100644 index 00000000000..77174ce4b6a --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/upsell-wrapper.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { CrownIcon, LockIcon } from "lucide-react"; +import Link from "next/link"; +import type React from "react"; +import type { Team } from "@/api/team/get-team"; +import { TeamPlanBadge } from "@/components/blocks/TeamPlanBadge"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +interface UpsellWrapperProps { + teamSlug: string; + children: React.ReactNode; + isLocked?: boolean; + requiredPlan: Team["billingPlan"]; + currentPlan?: Team["billingPlan"]; + isLegacyPlan?: boolean; + featureName: string; + featureDescription: string; + benefits?: { + description: string; + status: "available" | "soon"; + }[]; + className?: string; +} + +export function UpsellWrapper({ + teamSlug, + children, + isLocked = true, + requiredPlan, + currentPlan = "free", + featureName, + featureDescription, + benefits = [], + className, + isLegacyPlan = false, +}: UpsellWrapperProps) { + if (!isLocked) { + return <>{children}; + } + + return ( +
+ {/* Background content - blurred and non-interactive */} +
+
+ {children} +
+
+ + {/* Overlay gradient */} +
+ + {/* Upsell content */} +
+ +
+
+ ); +} + +export function UpsellContent(props: { + teamSlug: string; + featureName: string; + featureDescription: string; + requiredPlan: Team["billingPlan"]; + currentPlan: Team["billingPlan"]; + isLegacyPlan: boolean; + benefits?: { + description: string; + status: "available" | "soon"; + }[]; +}) { + return ( + + +
+ +
+ +
+ +
+ + Unlock {props.featureName} + + + {props.featureDescription} + +
+
+
+ + + {props.benefits && props.benefits.length > 0 && ( +
+

+ What you'll get +

+
+ {props.benefits.map((benefit) => ( +
+ {benefit.description} + {benefit.status === "soon" && ( + + Coming Soon + + )} +
+ ))} +
+
+ )} + +
+ + +
+ +
+

+ You are currently on the{" "} + {props.currentPlan}{" "} + plan. +

+
+
+
+ ); +} diff --git a/apps/dashboard/src/@/components/blocks/wallet-address.stories.tsx b/apps/dashboard/src/@/components/blocks/wallet-address.stories.tsx new file mode 100644 index 00000000000..0612e077e18 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/wallet-address.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb"; +import type { SocialProfile } from "thirdweb/react"; +import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils"; +import { WalletAddress, WalletAddressUI } from "./wallet-address"; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "blocks/WalletAddress", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story() { + const client = storybookThirdwebClient as unknown as ThirdwebClient; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +const vitalikEth: SocialProfile[] = [ + { + type: "ens", + name: "vitalik.eth", + bio: "mi pinxe lo crino tcati", + avatar: "https://euc.li/vitalik.eth", + metadata: { + name: "vitalik.eth", + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + avatar: "https://euc.li/vitalik.eth", + description: "mi pinxe lo crino tcati", + url: "https://vitalik.ca", + }, + }, + { + type: "farcaster", + name: "vitalik.eth", + bio: undefined, + avatar: + "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original", + metadata: { + fid: 5650, + bio: undefined, + pfp: "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original", + username: "vitalik.eth", + addresses: [ + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "0x96B6bB2bd2Eba3b4Fbefd7DbAC448ad7B6CBf279", + ], + }, + }, +]; diff --git a/apps/dashboard/src/@/components/blocks/wallet-address.tsx b/apps/dashboard/src/@/components/blocks/wallet-address.tsx index 962bcd32916..163c9b95ce9 100644 --- a/apps/dashboard/src/@/components/blocks/wallet-address.tsx +++ b/apps/dashboard/src/@/components/blocks/wallet-address.tsx @@ -1,153 +1,183 @@ "use client"; +import { CircleSlashIcon, XIcon } from "lucide-react"; +import { useMemo } from "react"; +import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb"; +import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react"; +import { Img } from "@/components/blocks/Img"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; -import { useThirdwebClient } from "@/constants/thirdweb.client"; -import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler"; -import { useClipboard } from "hooks/useClipboard"; -import { Check, Copy } from "lucide-react"; -import { useMemo } from "react"; -import { type ThirdwebClient, isAddress } from "thirdweb"; -import { ZERO_ADDRESS } from "thirdweb"; -import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react"; -import { cn } from "../../lib/utils"; -import { Badge } from "../ui/badge"; -import { Button } from "../ui/button"; -import { Img } from "./Img"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; +import { CopyTextButton } from "../ui/CopyTextButton"; +import { Skeleton } from "../ui/skeleton"; -export function WalletAddress(props: { +type WalletAddressProps = { address: string | undefined; shortenAddress?: boolean; className?: string; -}) { - const thirdwebClient = useThirdwebClient(); - // default back to zero address if no address provided - const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]); - - const [shortenedAddress, lessShortenedAddress] = useMemo(() => { - return [ - props.shortenAddress !== false - ? `${address.slice(0, 6)}...${address.slice(-4)}` - : address, - `${address.slice(0, 14)}...${address.slice(-12)}`, - ]; - }, [address, props.shortenAddress]); + iconClassName?: string; + client: ThirdwebClient; + fallbackIcon?: React.ReactNode; +}; +export function WalletAddress(props: WalletAddressProps) { const profiles = useSocialProfiles({ - address: address, - client: thirdwebClient, + address: props.address, + client: props.client, }); - const { onCopy, hasCopied } = useClipboard(address, 2000); + return ( + + ); +} + +export function WalletAddressUI( + props: WalletAddressProps & { + profiles: { + data: SocialProfile[]; + isPending: boolean; + }; + }, +) { + const address = props.address || ZERO_ADDRESS; + + const shortenedAddress = + props.shortenAddress !== false + ? `${address.slice(0, 6)}...${address.slice(-4)}` + : address; if (!isAddress(address)) { - return Invalid Address ({address}); + return ( + + +
+ +
+ Invalid Address +
+
+ ); } // special case for zero address if (address === ZERO_ADDRESS) { - return {shortenedAddress}; + return ( +
+ + + {shortenedAddress} + +
+ ); } return ( - + { // do not close the hover card when clicking anywhere in the content e.stopPropagation(); }} >
-
-

Wallet Address

- +
+

Wallet Address

+ +
-

- {lessShortenedAddress} -

-

Social Profiles

- {profiles.isPending ? ( -

Loading profiles...

- ) : !profiles.data?.length ? ( -

No profiles found

- ) : ( - profiles.data?.map((profile) => { - const walletAvatarLink = resolveSchemeWithErrorHandler({ - client: thirdwebClient, - uri: profile.avatar, - }); - - return ( -
- {walletAvatarLink && ( - - - {profile.name && ( - - {profile.name.slice(0, 2)} - - )} - - )} -
-
-

{profile.name}

- {profile.type} + +
+

Social Profiles

+ + {props.profiles.isPending ? ( + + ) : !props.profiles.data?.length ? ( +

No profiles found

+ ) : ( +
+ {props.profiles.data?.map((profile) => { + const walletAvatarLink = resolveSchemeWithErrorHandler({ + client: props.client, + uri: profile.avatar, + }); + + return ( +
+ + + {profile.name && ( + + {profile.name.slice(0, 2)} + + )} + + +
+

{profile.name}

+ + {profile.type === "ens" ? "ENS" : profile.type} + +
- {profile.bio && ( -

- {profile.bio} -

- )} -
-
- ); - }) - )} + ); + })} +
+ )} +
@@ -158,6 +188,8 @@ function WalletAvatar(props: { address: string; profiles: SocialProfile[]; thirdwebClient: ThirdwebClient; + iconClassName?: string; + fallbackIcon?: React.ReactNode; }) { const avatar = useMemo(() => { return props.profiles.find( @@ -176,11 +208,29 @@ function WalletAvatar(props: { : undefined; return ( -
+
{resolvedAvatarSrc ? ( - + + } + /> + ) : props.fallbackIcon ? ( + props.fallbackIcon ) : ( - + )}
); diff --git a/apps/dashboard/src/@/components/chat/ChatBar.tsx b/apps/dashboard/src/@/components/chat/ChatBar.tsx new file mode 100644 index 00000000000..24b39307843 --- /dev/null +++ b/apps/dashboard/src/@/components/chat/ChatBar.tsx @@ -0,0 +1,135 @@ +"use client"; +import { ArrowUpIcon, CircleStopIcon, PaperclipIcon } from "lucide-react"; +import { useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { AutoResizeTextarea } from "@/components/ui/textarea"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { NebulaUserMessage } from "./types"; + +export function ChatBar(props: { + sendMessage: (message: NebulaUserMessage) => void; + isChatStreaming: boolean; + abortChatStream: () => void; + prefillMessage: string | undefined; + className?: string; + client: ThirdwebClient; + isConnectingWallet: boolean; + onLoginClick: undefined | (() => void); + placeholder: string; +}) { + const [message, setMessage] = useState(props.prefillMessage || ""); + + function handleSubmit(message: string) { + const userMessage: NebulaUserMessage = { + content: [{ text: message, type: "text" }], + role: "user", + }; + + props.sendMessage(userMessage); + setMessage(""); + } + + return ( + +
+
+
+ setMessage(e.target.value)} + onKeyDown={(e) => { + // ignore if shift key is pressed to allow entering new lines + if (e.shiftKey) { + return; + } + if (e.key === "Enter" && !props.isChatStreaming) { + e.preventDefault(); + handleSubmit(message); + } + }} + placeholder={props.placeholder} + value={message} + /> +
+ +
+ {/* left */} +
+ + {/* right */} +
+ {props.onLoginClick ? ( + + + + + +
+

+ Get access to image uploads by signing in to thirdweb +

+ +
+
+
+ ) : null} + + {/* Send / Stop */} + {props.isChatStreaming ? ( + + ) : ( + + )} +
+
+
+
+ + ); +} diff --git a/apps/dashboard/src/@/components/chat/CustomChatButton.tsx b/apps/dashboard/src/@/components/chat/CustomChatButton.tsx new file mode 100644 index 00000000000..ad1e2dedda4 --- /dev/null +++ b/apps/dashboard/src/@/components/chat/CustomChatButton.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { CircleQuestionMarkIcon, XIcon } from "lucide-react"; +import { useSelectedLayoutSegments } from "next/navigation"; +import { useCallback, useRef, useState } from "react"; +import { createThirdwebClient } from "thirdweb"; +import type { Team } from "@/api/team/get-team"; +import { Button } from "@/components/ui/button"; +import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs"; +import { cn } from "@/lib/utils"; +import CustomChatContent from "./CustomChatContent"; + +// Create a thirdweb client for the chat functionality +const client = createThirdwebClient({ + clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID, +}); + +export function CustomChatButton(props: { + examplePrompts: string[]; + authToken: string; + team: Team; + clientId: string | undefined; +}) { + const layoutSegments = useSelectedLayoutSegments(); + const [isOpen, setIsOpen] = useState(false); + const [hasBeenOpened, setHasBeenOpened] = useState(false); + const closeModal = useCallback(() => setIsOpen(false), []); + const ref = useRef(null); + + if ( + (layoutSegments[0] === "~" && layoutSegments[1] === "support") || + layoutSegments.includes("ai") + ) { + return null; + } + + return ( + <> + {/* Inline Button (not floating) */} + + + {/* Popup/Modal */} +
+ {/* Header with close button */} +
+
+ Need help? +
+ +
+ {/* Chat Content */} +
+ {hasBeenOpened && isOpen && ( + ({ + message: prompt, + title: prompt, + }))} + team={props.team} + /> + )} +
+
+ + ); +} diff --git a/apps/dashboard/src/@/components/chat/CustomChatContent.tsx b/apps/dashboard/src/@/components/chat/CustomChatContent.tsx new file mode 100644 index 00000000000..dd4d2d5e364 --- /dev/null +++ b/apps/dashboard/src/@/components/chat/CustomChatContent.tsx @@ -0,0 +1,309 @@ +"use client"; +import { useCallback, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { useActiveWalletConnectionStatus } from "thirdweb/react"; +import type { Team } from "@/api/team/get-team"; +import { Button } from "@/components/ui/button"; +import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo"; +import { ChatBar } from "./ChatBar"; +import type { UserMessage, UserMessageContent } from "./CustomChats"; +import { type CustomChatMessage, CustomChats } from "./CustomChats"; +import type { ExamplePrompt } from "./types"; + +export default function CustomChatContent(props: { + authToken: string | undefined; + team: Team; + clientId: string | undefined; + client: ThirdwebClient; + examplePrompts: ExamplePrompt[]; +}) { + return ( + + ); +} + +function CustomChatContentLoggedIn(props: { + authToken: string; + team: Team; + clientId: string | undefined; + client: ThirdwebClient; + examplePrompts: ExamplePrompt[]; +}) { + const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false); + const [messages, setMessages] = useState>([]); + // sessionId is initially undefined, will be set to conversationId from API after first response + const [sessionId, setSessionId] = useState(undefined); + const [chatAbortController, setChatAbortController] = useState< + AbortController | undefined + >(); + + const [isChatStreaming, setIsChatStreaming] = useState(false); + const [enableAutoScroll, setEnableAutoScroll] = useState(false); + const connectionStatus = useActiveWalletConnectionStatus(); + + const [showSupportForm, setShowSupportForm] = useState(false); + const [productLabel, setProductLabel] = useState(""); + + const handleSendMessage = useCallback( + async (userMessage: UserMessage) => { + const abortController = new AbortController(); + setUserHasSubmittedMessage(true); + setIsChatStreaming(true); + setEnableAutoScroll(true); + + setMessages((prev) => [ + ...prev, + { + content: userMessage.content as UserMessageContent[], + type: "user", + }, + // instant loading indicator feedback to user + { + texts: [], + type: "presence", + }, + ]); + + // if this is first message, set the message prefix + // deep clone `userMessage` to avoid mutating the original message, its a pretty small object so JSON.parse is fine + const messageToSend = JSON.parse( + JSON.stringify(userMessage), + ) as UserMessage; + + try { + setChatAbortController(abortController); + // --- Custom API call --- + const payload = { + conversationId: sessionId, + message: + messageToSend.content.find((x) => x.type === "text")?.text ?? "", + source: "dashboard-support", + }; + const apiUrl = process.env.NEXT_PUBLIC_SIWA_URL; + const response = await fetch(`${apiUrl}/v1/chat`, { + body: JSON.stringify(payload), + headers: { + Authorization: `Bearer ${props.authToken}`, + "Content-Type": "application/json", + "x-team-id": props.team.id, + ...(props.clientId ? { "x-client-id": props.clientId } : {}), + }, + method: "POST", + signal: abortController.signal, + }); + const data = await response.json(); + // If the response contains a conversationId, set it as the sessionId for future messages + if (data.conversationId && data.conversationId !== sessionId) { + setSessionId(data.conversationId); + } + setMessages((prev) => [ + ...prev.slice(0, -1), // remove presence indicator + { + request_id: undefined, + text: data.data, + type: "assistant", + }, + ]); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + setMessages((prev) => [ + ...prev.slice(0, -1), + { + request_id: undefined, + text: `Sorry, something went wrong. ${error instanceof Error ? error.message : "Unknown error"}`, + type: "assistant", + }, + ]); + } finally { + setIsChatStreaming(false); + setEnableAutoScroll(false); + } + }, + [props.authToken, props.clientId, props.team.id, sessionId], + ); + + const handleFeedback = useCallback( + async (messageIndex: number, feedback: 1 | -1) => { + if (!sessionId) { + console.error("Cannot submit feedback: missing session ID"); + return; + } + + // Validate message exists and is of correct type + const message = messages[messageIndex]; + if (!message || message.type !== "assistant") { + console.error("Invalid message for feedback:", messageIndex); + return; + } + + // Prevent duplicate feedback + if (message.feedback) { + console.warn("Feedback already submitted for this message"); + return; + } + + try { + const apiUrl = process.env.NEXT_PUBLIC_SIWA_URL; + const response = await fetch(`${apiUrl}/v1/chat/feedback`, { + body: JSON.stringify({ + conversationId: sessionId, + feedbackRating: feedback, + }), + headers: { + Authorization: `Bearer ${props.authToken}`, + "Content-Type": "application/json", + "x-team-id": props.team.id, + }, + method: "POST", + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Update the message with feedback + setMessages((prev) => + prev.map((msg, index) => + index === messageIndex && msg.type === "assistant" + ? { ...msg, feedback } + : msg, + ), + ); + } catch (error) { + console.error("Failed to send feedback:", error); + // Optionally show user-facing error notification + // Consider implementing retry logic here + } + }, + [sessionId, props.authToken, props.team.id, messages], + ); + + const handleAddSuccessMessage = useCallback((message: string) => { + setMessages((prev) => [ + ...prev, + { + type: "assistant" as const, + text: message, + request_id: undefined, + }, + ]); + }, []); + + const showEmptyState = !userHasSubmittedMessage && messages.length === 0; + return ( +
+ {showEmptyState ? ( + + ) : ( + + )} + { + chatAbortController?.abort(); + setChatAbortController(undefined); + setIsChatStreaming(false); + // if last message is presence, remove it + if (messages[messages.length - 1]?.type === "presence") { + setMessages((prev) => prev.slice(0, -1)); + } + }} + className="rounded-none border-x-0 border-b-0" + client={props.client} + isChatStreaming={isChatStreaming} + isConnectingWallet={connectionStatus === "connecting"} + onLoginClick={undefined} + placeholder={"Ask AI Assistant"} + prefillMessage={undefined} + sendMessage={(siwaUserMessage) => { + const userMessage: UserMessage = { + content: siwaUserMessage.content + .filter((c) => c.type === "text") + .map((c) => ({ text: c.text, type: "text" })), + type: "user", + }; + handleSendMessage(userMessage); + }} + /> +
+ ); +} + +function EmptyStateChatPageContent(props: { + sendMessage: (message: UserMessage) => void; + examplePrompts: { title: string; message: string }[]; +}) { + return ( +
+
+
+
+ +
+
+
+ +

+ How can I help you
+ today? +

+ +
+
+ {props.examplePrompts.map((prompt) => ( + + ))} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/chat/CustomChats.tsx b/apps/dashboard/src/@/components/chat/CustomChats.tsx new file mode 100644 index 00000000000..2990b73c51d --- /dev/null +++ b/apps/dashboard/src/@/components/chat/CustomChats.tsx @@ -0,0 +1,429 @@ +import { + AlertCircleIcon, + ArrowRightIcon, + ThumbsDownIcon, + ThumbsUpIcon, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import type { Team } from "@/api/team/get-team"; +import { MarkdownRenderer } from "@/components/blocks/markdown-renderer"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollShadow } from "@/components/ui/ScrollShadow"; +import { cn } from "@/lib/utils"; +import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo"; +import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm"; +import { Button } from "../ui/button"; +import { TextShimmer } from "../ui/text-shimmer"; + +// Define local types +export type UserMessageContent = { type: "text"; text: string }; +export type UserMessage = { + type: "user"; + content: UserMessageContent[]; +}; + +export type CustomChatMessage = + | UserMessage + | { + text: string; + type: "error"; + } + | { + texts: string[]; + type: "presence"; + } + | { + request_id: string | undefined; + text: string; + type: "assistant"; + feedback?: 1 | -1; + }; + +export function CustomChats(props: { + messages: Array; + isChatStreaming: boolean; + authToken: string; + sessionId: string | undefined; + className?: string; + client: ThirdwebClient; + setEnableAutoScroll: (enable: boolean) => void; + enableAutoScroll: boolean; + useSmallText?: boolean; + sendMessage: (message: UserMessage) => void; + onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise; + showSupportForm: boolean; + setShowSupportForm: (v: boolean) => void; + productLabel: string; + setProductLabel: (v: string) => void; + team: Team; + addSuccessMessage?: (message: string) => void; +}) { + const { messages, setEnableAutoScroll, enableAutoScroll } = props; + const scrollAnchorRef = useRef(null); + const chatContainerRef = useRef(null); + const [supportTicketCreated, setSupportTicketCreated] = useState(false); + + // auto scroll to bottom when messages change + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!enableAutoScroll || messages.length === 0) { + return; + } + + scrollAnchorRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, enableAutoScroll]); + + // stop auto scrolling when user interacts with chat + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!enableAutoScroll) { + return; + } + + const chatScrollContainer = + chatContainerRef.current?.querySelector("[data-scrollable]"); + + if (!chatScrollContainer) { + return; + } + + const disableScroll = () => { + setEnableAutoScroll(false); + chatScrollContainer.removeEventListener("mousedown", disableScroll); + chatScrollContainer.removeEventListener("wheel", disableScroll); + }; + + chatScrollContainer.addEventListener("mousedown", disableScroll); + chatScrollContainer.addEventListener("wheel", disableScroll); + }, [setEnableAutoScroll, enableAutoScroll]); + + const [showSupportFormDialog, setShowSupportFormDialog] = useState(false); + + return ( +
+ +
+
+ {props.messages.map((message, index) => { + const isMessagePending = + props.isChatStreaming && index === props.messages.length - 1; + + return ( +
+ + {/* Support Case Button/Form in last assistant message */} + {message.type === "assistant" && + index === props.messages.length - 1 && ( + <> + {/* Only show button/form if ticket not created */} + {!supportTicketCreated && ( +
+ + + + + + + + Create Support Case + +

+ Let's create a detailed support case for + our technical team. +

+
+ { + setShowSupportFormDialog(false); + }} + onSuccess={() => { + props.setShowSupportForm(false); + props.setProductLabel(""); + setSupportTicketCreated(true); + // Add success message as a regular assistant message + if (props.addSuccessMessage) { + const supportPortalUrl = `/team/${props.team.slug}/~/support`; + props.addSuccessMessage( + `Your support ticket has been created! Our team will get back to you soon. You can also visit the [support portal](${supportPortalUrl}) to track your case.`, + ); + } + }} + /> +
+
+
+
+ )} + + )} +
+ ); + })} +
+
+
+ +
+ ); +} + +function RenderMessage(props: { + message: CustomChatMessage; + messageIndex: number; + isMessagePending: boolean; + client: ThirdwebClient; + sendMessage: (message: UserMessage) => void; + nextMessage: CustomChatMessage | undefined; + authToken: string; + sessionId: string | undefined; + onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise; +}) { + const { message } = props; + + if (props.message.type === "user") { + return ( +
+ {props.message.content.map((msg, index) => { + if (msg.type === "text") { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED +
+
+ +
+
+ ); + } + + return null; + })} +
+ ); + } + + return ( +
+ {/* Left Icon */} +
+
+ {(message.type === "presence" || message.type === "assistant") && ( + + )} + + {message.type === "error" && ( + + )} +
+
+ + {/* Right Message */} +
+ + + + + {/* Custom Feedback Buttons */} + {message.type === "assistant" && + !props.isMessagePending && + props.onFeedback && ( + + )} +
+
+ ); +} + +function CustomFeedbackButtons(props: { + message: CustomChatMessage & { type: "assistant" }; + messageIndex: number; + onFeedback: (messageIndex: number, feedback: 1 | -1) => Promise; + className?: string; +}) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleFeedback = async (feedback: 1 | -1) => { + if (isSubmitting || props.message.feedback) return; + + setIsSubmitting(true); + try { + await props.onFeedback(props.messageIndex, feedback); + } catch (_e) { + // Handle error silently + } + + setIsSubmitting(false); + }; + + // Don't show buttons if feedback already given + if (props.message.feedback) { + return null; + } + + return ( +
+ + +
+ ); +} + +function RenderResponse(props: { + message: CustomChatMessage; + isMessagePending: boolean; + client: ThirdwebClient; + sendMessage: (message: UserMessage) => void; + nextMessage: CustomChatMessage | undefined; + sessionId: string | undefined; + authToken: string; +}) { + const { message, isMessagePending } = props; + + switch (message.type) { + case "assistant": + return ( + + ); + + case "presence": + return ( +
+ +
+ ); + + case "error": + return ( +
+ {message.text} +
+ ); + + case "user": { + return null; + } + + default: { + // This ensures TypeScript will catch if we miss a case + const _exhaustive: never = message; + console.error("Unhandled message type:", _exhaustive); + return null; + } + } +} + +function StyledMarkdownRenderer(props: { + text: string; + isMessagePending: boolean; + type: "assistant" | "user"; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/@/components/chat/types.ts b/apps/dashboard/src/@/components/chat/types.ts new file mode 100644 index 00000000000..b640dc94840 --- /dev/null +++ b/apps/dashboard/src/@/components/chat/types.ts @@ -0,0 +1,30 @@ +export type ExamplePrompt = { + title: string; + message: string; + interceptedReply?: string; +}; + +// TODO - remove "Nebula" wording and simplify types + +type NebulaUserMessageContentItem = + | { + type: "image"; + image_url: string | null; + b64: string | null; + } + | { + type: "text"; + text: string; + } + | { + type: "transaction"; + transaction_hash: string; + chain_id: number; + }; + +type NebulaUserMessageContent = NebulaUserMessageContentItem[]; + +export type NebulaUserMessage = { + role: "user"; + content: NebulaUserMessageContent; +}; diff --git a/apps/dashboard/src/@/components/cmd-k-search/index.tsx b/apps/dashboard/src/@/components/cmd-k-search/index.tsx new file mode 100644 index 00000000000..f5bc8bc28d8 --- /dev/null +++ b/apps/dashboard/src/@/components/cmd-k-search/index.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { + keepPreviousData, + type QueryClient, + queryOptions, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { ArrowRightIcon, CommandIcon, SearchIcon, XIcon } from "lucide-react"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { useDebounce } from "use-debounce"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ScrollShadow } from "@/components/ui/ScrollShadow"; +import { Spinner } from "@/components/ui/Spinner"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { fetchTopContracts, type TrendingContract } from "@/lib/search"; +import { cn } from "@/lib/utils"; +import { shortenIfAddress } from "@/utils/usedapp-external"; + +const typesenseApiKey = + process.env.NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY || ""; + +function contractTypesenseSearchQuery( + searchQuery: string, + queryClient: QueryClient, +) { + return queryOptions({ + enabled: !!searchQuery && !!queryClient && !!typesenseApiKey, + placeholderData: keepPreviousData, + queryFn: async () => { + return fetchTopContracts({ + perPage: 10, + query: searchQuery, + timeRange: "month", + }); + }, + queryKey: ["typesense-contract-search", { search: searchQuery }], + }); +} + +export const CmdKSearchModal = (props: { + open: boolean; + setOpen: React.Dispatch>; + client: ThirdwebClient; +}) => { + const { open, setOpen } = props; + const queryClient = useQueryClient(); + + // legitimate use-case + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && e.metaKey) { + setOpen((open_) => !open_); + } + }; + + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [setOpen]); + + const [searchValue, setSearchValue] = useState(""); + + // debounce 500ms + const [debouncedSearchValue] = useDebounce(searchValue, 500); + + const typesenseSearchQuery = useQuery( + contractTypesenseSearchQuery(debouncedSearchValue, queryClient), + ); + + const data = useMemo(() => { + const potentiallyDuplicated = [...(typesenseSearchQuery.data || [])].filter( + (d) => !!d, + ) as TrendingContract[]; + + // dedupe the results + return Array.from( + new Set( + potentiallyDuplicated.map( + (d) => `${d.chainMetadata.chainId}_${d.contractAddress}`, + ), + ), + ).map((chainIdAndAddress) => { + return potentiallyDuplicated.find((d) => { + return ( + `${d.chainMetadata.chainId}_${d.contractAddress}` === + chainIdAndAddress + ); + }); + }) as TrendingContract[]; + }, [typesenseSearchQuery]); + + const isFetching = useMemo(() => { + return ( + typesenseSearchQuery.isFetching || debouncedSearchValue !== searchValue + ); + }, [debouncedSearchValue, searchValue, typesenseSearchQuery.isFetching]); + const [activeIndex, setActiveIndex] = useState(0); + + const router = useDashboardRouter(); + + const handleClose = useCallback(() => { + setOpen(false); + setSearchValue(""); + setActiveIndex(0); + }, [setOpen]); + + // legitimate use-case + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // re-set the active index if we are fetching + if (isFetching && !data.length) { + setActiveIndex(0); + } + }, [data.length, isFetching]); + + // legitimate use-case + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // only if the modal is open + if (!open) { + return; + } + const down = (e: KeyboardEvent) => { + // if something is selected and we press enter or space we should go to the contract + if (e.key === "Enter" && data) { + const result = data[activeIndex]; + if (result) { + e.preventDefault(); + router.push( + `/${result.chainMetadata.chainId}/${result.contractAddress}`, + ); + + handleClose(); + } + } else if (e.key === "ArrowDown") { + // if we press down we should move the selection down + e.preventDefault(); + setActiveIndex((aIndex) => { + if (data) { + return Math.min(aIndex + 1, data.length - 1); + } + return aIndex; + }); + } else if (e.key === "ArrowUp") { + // if we press up we should move the selection up + e.preventDefault(); + setActiveIndex((aIndex) => Math.max(aIndex - 1, 0)); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [activeIndex, data, handleClose, open, router]); + + return ( + { + if (!isOpen) { + handleClose(); + } + }} + open={open} + > + + {/* Title */} + +
+ + Search Contracts + + Search a contract by name or contract address across all + blockchains + + + + {/* Search */} +
+ + setSearchValue(e.target.value)} + placeholder="Name or Contract Address" + value={searchValue} + /> +
+ {isFetching ? ( + + ) : searchValue.length > 0 ? ( + + ) : null} +
+
+
+ + {searchValue.length > 0 && (!isFetching || data.length) ? ( +
+ + {!data || data?.length === 0 ? ( +
+ No contracts found +
+ ) : ( +
+ {data.map((result, idx) => { + return ( + { + handleClose(); + }} + onMouseEnter={() => setActiveIndex(idx)} + result={result} + /> + ); + })} +
+ )} +
+
+ ) : null} +
+
+
+ ); +}; + +export const CmdKSearch = (props: { + className?: string; + client: ThirdwebClient; +}) => { + const [open, setOpen] = useState(false); + + return ( + <> +
+ setOpen(true)} + placeholder="Search any contract" + /> +
+ K +
+
+ + + + + + ); +}; + +interface SearchResultProps { + result: TrendingContract; + isActive: boolean; + onMouseEnter: () => void; + onClick: () => void; + client: ThirdwebClient; +} + +const SearchResult: React.FC = ({ + result, + isActive, + onMouseEnter, + onClick, + client, +}) => { + return ( +
+ +
+

+ + {shortenIfAddress(result.name)} + +

+

+ {result.chainMetadata.name} -{" "} + + {shortenIfAddress(result.contractAddress)} + +

+
+
+ +
+
+ ); +}; diff --git a/apps/dashboard/src/@/components/color-mode-toggle.tsx b/apps/dashboard/src/@/components/color-mode-toggle.tsx deleted file mode 100644 index 1fb5010b142..00000000000 --- a/apps/dashboard/src/@/components/color-mode-toggle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { ClientOnly } from "components/ClientOnly/ClientOnly"; -import { Moon, Sun } from "lucide-react"; -import { useTheme } from "next-themes"; -import { Skeleton } from "./ui/skeleton"; - -export function ColorModeToggle() { - const { setTheme, theme } = useTheme(); - - return ( - }> - - - ); -} diff --git a/apps/dashboard/src/@/components/connect-wallet/index.tsx b/apps/dashboard/src/@/components/connect-wallet/index.tsx new file mode 100644 index 00000000000..1a755e705e8 --- /dev/null +++ b/apps/dashboard/src/@/components/connect-wallet/index.tsx @@ -0,0 +1,296 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTheme } from "next-themes"; +import { useCallback, useMemo, useState } from "react"; +import type { Chain, ThirdwebClient } from "thirdweb"; +import { + ConnectButton, + type NetworkSelectorProps, + useActiveAccount, + useConnectModal, +} from "thirdweb/react"; +import { doLogout } from "@/actions/auth-actions"; +import { resetAnalytics } from "@/analytics/reset"; +import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer"; +import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal"; +import { Button } from "@/components/ui/button"; +import { appMetadata } from "@/constants/connect"; +import { popularChains } from "@/constants/popularChains"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { useFavoriteChainIds } from "@/hooks/favorite-chains"; +import { useStore } from "@/lib/reactive"; +import { + addRecentlyUsedChainId, + recentlyUsedChainIdsStore, + type StoredChain, +} from "@/stores/chainStores"; +import { mapV4ChainToV5Chain } from "@/utils/map-chains"; +import { getSDKTheme } from "@/utils/sdk-component-theme"; + +export const CustomConnectWallet = (props: { + loginRequired?: boolean; + connectButtonClassName?: string; + signInLinkButtonClassName?: string; + detailsButtonClassName?: string; + chain?: Chain; + client: ThirdwebClient; + isLoggedIn: boolean; +}) => { + const client = props.client; + + const loginRequired = + props.loginRequired === undefined ? true : props.loginRequired; + + const { theme } = useTheme(); + const t = theme === "light" ? "light" : "dark"; + + // chains + const favChainIdsQuery = useFavoriteChainIds(); + const recentChainIds = useStore(recentlyUsedChainIdsStore); + const { idToChain, allChainsV5 } = useAllChainsData(); + + const recentlyUsedChainsWithMetadata = useMemo( + () => + recentChainIds + .map((id) => { + const c = idToChain.get(id); + // eslint-disable-next-line no-restricted-syntax + return c ? mapV4ChainToV5Chain(c) : undefined; + }) + .filter((x) => !!x), + [recentChainIds, idToChain], + ); + + const favoriteChainsWithMetadata = useMemo(() => { + return (favChainIdsQuery.data || []) + .map((id) => { + const c = idToChain.get(Number(id)); + // eslint-disable-next-line no-restricted-syntax + return c ? mapV4ChainToV5Chain(c) : undefined; + }) + .filter((x) => !!x); + }, [idToChain, favChainIdsQuery.data]); + + const popularChainsWithMetadata = useMemo(() => { + // eslint-disable-next-line no-restricted-syntax + return popularChains.map((x) => + // eslint-disable-next-line no-restricted-syntax + { + const v4Chain = idToChain.get(x.id); + // eslint-disable-next-line no-restricted-syntax + return v4Chain ? mapV4ChainToV5Chain(v4Chain) : x; + }, + ); + }, [idToChain]); + + // Network Config Modal + const [isNetworkConfigModalOpen, setIsNetworkConfigModalOpen] = + useState(false); + const [editChain, setEditChain] = useState( + undefined, + ); + + const chainSections: NetworkSelectorProps["sections"] = useMemo(() => { + return [ + { + chains: recentlyUsedChainsWithMetadata, + label: "Recent", + }, + { + chains: favoriteChainsWithMetadata, + label: "Favorites", + }, + { + chains: popularChainsWithMetadata, + label: "Popular", + }, + ]; + }, [ + recentlyUsedChainsWithMetadata, + favoriteChainsWithMetadata, + popularChainsWithMetadata, + ]); + + // ensures login status on pages that need it + const { isLoggedIn } = props; + const pathname = usePathname(); + const account = useActiveAccount(); + + if ((!isLoggedIn || !account) && loginRequired) { + return ( + + ); + } + + return ( + <> + , + }} + detailsButton={{ + className: props.detailsButtonClassName, + }} + detailsModal={{ + networkSelector: { + onCustomClick: () => { + setEditChain(undefined); + setIsNetworkConfigModalOpen(true); + }, + onSwitch(chain) { + addRecentlyUsedChainId(chain.id); + }, + renderChain(props) { + return ( + { + setIsNetworkConfigModalOpen(true); + setEditChain(c); + }} + /> + ); + }, + sections: chainSections, + }, + }} + onDisconnect={async () => { + try { + await doLogout(); + resetAnalytics(); + } catch (err) { + console.error("Failed to log out", err); + } + }} + theme={getSDKTheme(t)} + /> + + + + ); +}; + +function ConnectWalletWelcomeScreen(props: { + theme: "light" | "dark"; + subtitle?: string; +}) { + const fontColor = props.theme === "light" ? "black" : "white"; + const subtitle = props.subtitle ?? "Connect your wallet to get started"; + + return ( +
+
+
+
+ +
+ +
+

+ Welcome to thirdweb +

+ +
+ +

+ {subtitle} +

+
+
+ + + New to Wallets? + +
+ ); +} + +export function useCustomConnectModal() { + const { connect } = useConnectModal(); + const { theme } = useTheme(); + + return useCallback( + (options: { chain?: Chain; client: ThirdwebClient }) => { + return connect({ + appMetadata, + chain: options?.chain, + client: options.client, + privacyPolicyUrl: "/privacy-policy", + showThirdwebBranding: false, + termsOfServiceUrl: "/terms", + theme: getSDKTheme(theme === "light" ? "light" : "dark"), + welcomeScreen: () => ( + + ), + }); + }, + [connect, theme], + ); +} diff --git a/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx b/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx new file mode 100644 index 00000000000..52a6318a546 --- /dev/null +++ b/apps/dashboard/src/@/components/contract-components/shared/contract-id-image.tsx @@ -0,0 +1,50 @@ +/* eslint-disable @next/next/no-img-element */ + +import type { StaticImageData } from "next/image"; +import Image from "next/image"; +import type { FetchDeployMetadataResult } from "thirdweb/contract"; +import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; +import generalContractIcon from "../../../../../public/assets/tw-icons/general.png"; + +type ContractIdImageProps = { + deployedMetadataResult: FetchDeployMetadataResult; +}; + +export const ContractIdImage: React.FC = ({ + deployedMetadataResult, +}) => { + const logo = deployedMetadataResult.logo; + + const img = + deployedMetadataResult.image !== "custom" + ? deployedMetadataResult.image || generalContractIcon + : generalContractIcon; + + if (logo) { + return ( + + ); + } + + if (typeof img !== "string") { + return {""}; + } + + return {""}; +}; diff --git a/apps/dashboard/src/components/selects/NetworkSelectDropdown.tsx b/apps/dashboard/src/@/components/contract-components/tables/NetworkSelectDropdown.tsx similarity index 80% rename from apps/dashboard/src/components/selects/NetworkSelectDropdown.tsx rename to apps/dashboard/src/@/components/contract-components/tables/NetworkSelectDropdown.tsx index 55c59002a28..59f1ab30d56 100644 --- a/apps/dashboard/src/components/selects/NetworkSelectDropdown.tsx +++ b/apps/dashboard/src/@/components/contract-components/tables/NetworkSelectDropdown.tsx @@ -1,3 +1,5 @@ +import { useMemo } from "react"; +import type { ThirdwebClient } from "thirdweb"; import { Select, SelectContent, @@ -5,9 +7,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { ChainIcon } from "components/icons/ChainIcon"; -import { useMemo } from "react"; -import { useAllChainsData } from "../../hooks/chains/allChains"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { ChainIconClient } from "@/icons/ChainIcon"; interface NetworkSelectDropdownProps { enabledChainIds?: number[]; @@ -16,6 +17,7 @@ interface NetworkSelectDropdownProps { isDisabled?: boolean; onSelect: (chain: string | undefined) => void; selectedChain: string | undefined; + client: ThirdwebClient; } export const NetworkSelectDropdown: React.FC = ({ @@ -25,6 +27,7 @@ export const NetworkSelectDropdown: React.FC = ({ isDisabled, selectedChain, onSelect, + client, }) => { const { allChains } = useAllChainsData(); @@ -52,26 +55,38 @@ export const NetworkSelectDropdown: React.FC = ({ return ( setKeywordSearch(e.target.value)} + placeholder="Search" + value={_keywordSearch} + /> +
+
+ +
+ {activeTab === "write" && + writeFunctions.length > 0 && + writeFunctions.map((e) => functionSection(e))} + + {activeTab === "read" && + viewFunctions.length > 0 && + viewFunctions.map((e) => functionSection(e))} +
+
+ )} + + {events.length > 0 && selectedFunction && ( +
+ {events.map((fn) => ( + + ))} +
+ )} +
+ + {/* right */} +
+ {selectedFunction && ( + + )} +
+
+ ); +}; + +interface FunctionsOrEventsListItemProps { + fn: AbiFunction | AbiEvent; + selectedFunction: AbiFunction | AbiEvent; + setSelectedFunction: Dispatch< + SetStateAction + >; +} + +const FunctionsOrEventsListItem: React.FC = ({ + fn, + selectedFunction, + setSelectedFunction, +}) => { + const isActive = + selectedFunction?.name === fn.name && + selectedFunction.inputs?.length === fn.inputs?.length; + return ( + + ); +}; diff --git a/apps/dashboard/src/@/components/contracts/functions/interactive-abi-function.tsx b/apps/dashboard/src/@/components/contracts/functions/interactive-abi-function.tsx new file mode 100644 index 00000000000..c1f2973ce65 --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/functions/interactive-abi-function.tsx @@ -0,0 +1,541 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { type AbiFunction, type AbiParameter, formatAbiItem } from "abitype"; +import { + ExternalLinkIcon, + InfoIcon, + PlayIcon, + RefreshCcwIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useId, useMemo, useState } from "react"; +import { FormProvider, useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { + prepareContractCall, + readContract, + simulateTransaction, + type ThirdwebContract, + toSerializableTransaction, + toWei, +} from "thirdweb"; +import { useActiveAccount } from "thirdweb/react"; +import { parseAbiParams, stringify, toFunctionSelector } from "thirdweb/utils"; +import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { SolidityInput } from "@/components/solidity-inputs"; +import { camelToTitle } from "@/components/solidity-inputs/helpers"; +import { TransactionButton } from "@/components/tx-button"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { PlainTextCodeBlock } from "@/components/ui/code/plaintext-code"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/Spinner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useSendAndConfirmTx } from "@/hooks/useSendTx"; + +function formatResponseData(data: unknown): { + type: "json" | "text"; + data: string; +} { + // Early exit if data is already a string, + // otherwise JSON.stringify(data) will wrap it in extra quotes - which will affect the value for [Copy button] + if (typeof data === "string") { + // "" is a valid response. For example, some token has `symbol` === "" + if (data === "") { + return { data: `""`, type: "text" }; + } + return { data, type: "text" }; + } + if (typeof data === "bigint") { + return { + data: data.toString(), + type: "text", + }; + } + + if (typeof data === "object") { + // biome-ignore lint/suspicious/noExplicitAny: FIXME + const receipt: any = (data as any).receipt; + if (receipt) { + data = { + events: receipt.events, + from: receipt.from, + to: receipt.to, + transactionHash: receipt.transactionHash, + }; + } + } + + return { + data: stringify(data, null, 2), + type: "json", + }; +} + +function formatError(error: Error): string { + if ((error as Error).message) { + return (error as Error).message; + } + // biome-ignore lint/suspicious/noExplicitAny: FIXME + if ((error as any).reason) { + // biome-ignore lint/suspicious/noExplicitAny: FIXME + return (error as any).reason; + } + + try { + return JSON.stringify(error); + } catch { + return error.toString(); + } +} + +function formatContractCall( + params: { + key: string; + value: string; + type: string; + components: Readonly | undefined; + }[], +) { + const parsedParams = params + .map((p) => (p.type === "bool" ? p.value !== "false" : p.value)) + .map((p) => { + try { + const parsed = JSON.parse(p as string); + if (Array.isArray(parsed) || typeof parsed === "object") { + return parsed; + } + // Return original value if its not an array or object + return p; + } catch { + // JSON.parse on string will throw an error + return p; + } + }); + return parsedParams; +} + +type InteractiveAbiFunctionProps = { + abiFunction: AbiFunction; + contract: ThirdwebContract; + isLoggedIn: boolean; +}; + +function useAsyncRead(contract: ThirdwebContract, abiFunction: AbiFunction) { + const formattedAbi = formatAbiItem({ + ...abiFunction, + type: "function", + // biome-ignore lint/suspicious/noExplicitAny: FIXME + } as any); + return useMutation({ + mutationFn: async ({ + args, + types, + }: { + args: unknown[]; + types: string[]; + }) => { + const params = parseAbiParams(types, args); + return readContract({ + contract, + // biome-ignore lint/suspicious/noExplicitAny: dynamic typing + method: formattedAbi as any, + params, + }); + }, + }); +} + +function useSimulateTransaction() { + const from = useActiveAccount()?.address; + return useMutation({ + mutationFn: async ({ + contract, + abiFunction, + params, + value, + }: { + contract: ThirdwebContract; + abiFunction: AbiFunction; + params: unknown[]; + value?: bigint; + }) => { + if (!from) { + return toast.error("No account connected"); + } + const formattedAbi = formatAbiItem({ + ...abiFunction, + type: "function", + // biome-ignore lint/suspicious/noExplicitAny: FIXME + } as any); + console.log( + "formattedAbi", + formattedAbi, + toFunctionSelector(abiFunction), + ); + const transaction = prepareContractCall({ + contract, + // biome-ignore lint/suspicious/noExplicitAny: dynamic typing + method: formattedAbi as any, + params, + value, + }); + try { + const [simulateResult, populatedTransaction] = await Promise.all([ + simulateTransaction({ + from, + transaction, + }).catch((e) => { + console.error("Error simulating transaction", e); + throw e; + }), + toSerializableTransaction({ + from, + transaction, + }).catch((e) => { + console.error("Error serializing transaction", e); + throw e; + }), + ]); + return `--- ✅ Simulation succeeded --- +Result: ${simulateResult.length ? simulateResult.join(", ") : "Method did not return a result."} +Transaction data: +${Object.keys(populatedTransaction) + .map((key) => { + let _val = populatedTransaction[key as keyof typeof populatedTransaction]; + if (key === "value" && !_val) { + _val = 0; + } + return `${key}: ${_val}\n`; + }) + .join("")}`; + } catch (err) { + throw new Error(`--- ❌ Simulation failed --- +${(err as Error).message || ""}`); + } + }, + }); +} + +export const InteractiveAbiFunction: React.FC = ( + props, +) => { + const { abiFunction, contract } = props; + + const formId = useId(); + const form = useForm({ + defaultValues: { + params: + abiFunction.inputs.map((i) => ({ + components: "components" in i ? i.components : undefined, + key: i.name || "key", + type: i.type, + value: "", + })) || [], + value: "0", + }, + }); + const { fields } = useFieldArray({ + control: form.control, + name: "params", + }); + + const isView = useMemo(() => { + return ( + !abiFunction || + abiFunction.stateMutability === "view" || + abiFunction.stateMutability === "pure" + ); + }, [abiFunction]); + + const [executionMode, setExecutionMode] = useState< + "read" | "write" | "simulate" + >( + abiFunction.stateMutability === "view" || + abiFunction.stateMutability === "pure" + ? "read" + : "write", + ); + + const { + mutate, + data: mutationData, + error: mutationError, + isPending: mutationLoading, + } = useSendAndConfirmTx(); + + const { + mutate: readFn, + data: readData, + isPending: readLoading, + error: readError, + } = useAsyncRead(contract, abiFunction); + + const txSimulation = useSimulateTransaction(); + + const error = + executionMode === "read" + ? readError + : executionMode === "write" + ? mutationError + : executionMode === "simulate" + ? txSimulation.error + : undefined; + + const data = + executionMode === "read" + ? readData + : executionMode === "write" + ? mutationData + : executionMode === "simulate" + ? txSimulation.data + : undefined; + + const formattedResponseData = useMemo( + () => (data !== undefined ? formatResponseData(data) : ""), + [data], + ); + + // legitimate(?) use-case + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if ( + form.watch("params").length === 0 && + (abiFunction.stateMutability === "view" || + abiFunction.stateMutability === "pure") + ) { + readFn({ + args: [], + types: [], + }); + } + }, [abiFunction, form, readFn]); + + const handleContractRead = form.handleSubmit((d) => { + setExecutionMode("read"); + const types = abiFunction.inputs.map((o) => o.type); + const formatted = formatContractCall(d.params); + readFn({ args: formatted, types }); + }); + + const handleContractWrite = form.handleSubmit((d) => { + setExecutionMode("write"); + if (!abiFunction.name) { + return toast.error("Cannot detect function name"); + } + const formattedAbi = formatAbiItem({ + ...abiFunction, + type: "function", + // biome-ignore lint/suspicious/noExplicitAny: FIXME + } as any); + const types = abiFunction.inputs.map((o) => o.type); + const formatted = formatContractCall(d.params); + const params = parseAbiParams(types, formatted); + const transaction = prepareContractCall({ + contract, + // biome-ignore lint/suspicious/noExplicitAny: dynamic typing + method: formattedAbi as any, + params, + value: d.value ? toWei(d.value) : undefined, + }); + mutate(transaction); + }); + + const handleContractSimulation = form.handleSubmit((d) => { + setExecutionMode("simulate"); + if (!abiFunction.name) { + return toast.error("Cannot detect function name"); + } + const types = abiFunction.inputs.map((o) => o.type); + const formatted = formatContractCall(d.params); + const params = parseAbiParams(types, formatted); + txSimulation.mutate({ + abiFunction, + contract, + params, + value: d.value ? toWei(d.value) : undefined, + }); + }); + + return ( + +
+
+ {fields.length > 0 && + fields.map((item, index) => { + const fieldError = form.getFieldState( + `params.${index}.value`, + form.formState, + ).error; + + return ( + + {camelToTitle(item.key)} + + {item.key} + +
+ } + errorMessage={fieldError?.message} + isRequired={false} + className="mb-2" + > + + + ); + })} + + {abiFunction.stateMutability === "payable" && ( + + + + )} + + {error ? ( +
+

Error

+ +
+ ) : readLoading ? ( + + ) : formattedResponseData ? ( + <> +
+

Output

+ {/* Show the Solidity type of the function's output */} + {abiFunction.outputs.length > 0 && ( + + {abiFunction.outputs[0]?.type} + + )} +
+ + {formattedResponseData.type === "text" ? ( + + ) : ( + + )} + + {/* If the result is an IPFS URI, show a handy link so that users can open it in a new tab */} + {formattedResponseData.type === "text" && + formattedResponseData.data.startsWith("ipfs://") && ( + + Open In IPFS Gateway + + + )} + + {/* Same with the logic above but this time it's applied to traditional urls */} + {((formattedResponseData.type === "text" && + formattedResponseData.data.startsWith("https://")) || + formattedResponseData.data.startsWith("http://")) && ( + + Open link + + + )} + + ) : null} + + +
+ {isView ? ( + + ) : ( + <> + + + + + Execute + + + )} +
+
+ + ); +}; diff --git a/apps/dashboard/src/@/components/contracts/import-contract/add-to-project-selector.tsx b/apps/dashboard/src/@/components/contracts/import-contract/add-to-project-selector.tsx new file mode 100644 index 00000000000..caa9fc4efdd --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/import-contract/add-to-project-selector.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useId } from "react"; +import type { ThirdwebClient } from "thirdweb"; + +import { GradientAvatar } from "@/components/blocks/avatar/gradient-avatar"; +import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { MinimalTeamsAndProjects, TeamAndProjectSelection } from "./types"; + +function SlashSeparator() { + return
; +} + +export function AddToProjectSelector(props: { + selection: TeamAndProjectSelection; + onSelectionChange: (selection: TeamAndProjectSelection) => void; + teamsAndProjects: MinimalTeamsAndProjects; + client: ThirdwebClient; +}) { + const selectedTeam = props.teamsAndProjects.find( + (t) => t.team.id === props.selection.team?.id, + ); + + const teamSelectId = useId(); + const projectSelectId = useId(); + + return ( +
+ {/* Team */} +
+ + +
+ + {/* Slash */} +
+ +
+ + {/* Project */} +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/contracts/import-contract/modal.tsx b/apps/dashboard/src/@/components/contracts/import-contract/modal.tsx new file mode 100644 index 00000000000..aa509ccabd2 --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/import-contract/modal.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { UnderlineLink } from "@workspace/ui/components/UnderlineLink"; +import { + ArrowDownToLineIcon, + CircleAlertIcon, + ExternalLinkIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { + getAddress, + getContract, + isAddress, + type ThirdwebClient, +} from "thirdweb"; +import { defineChain } from "thirdweb/chains"; +import { useActiveWalletChain } from "thirdweb/react"; +import { z } from "zod"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/Spinner"; +import { useChainSlug } from "@/hooks/chains/chainSlug"; +import { useAddContractToProject } from "@/hooks/project-contracts"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { revalidateCacheTagAction } from "../../../actions/revalidate"; +import { projectContractsCacheTag } from "../../../api/project/cache-tag"; +import { resolveFunctionSelectors } from "../../../lib/selectors"; +import { supportedERCs } from "../../../utils/supportedERCs"; +import { Alert, AlertDescription, AlertTitle } from "../../ui/alert"; + +type ImportModalProps = { + isOpen: boolean; + onClose: () => void; + teamId: string; + projectId: string; + projectSlug: string; + teamSlug: string; + client: ThirdwebClient; + type: "contract" | "asset"; + onSuccess?: () => void; + allowedContractType: "token" | "non-token"; +}; + +export const ImportModal: React.FC = (props) => { + return ( + { + if (!v) { + props.onClose(); + } + }} + open={props.isOpen} + > + + + + Import {props.type === "contract" ? "Contract" : "Token"} + + + Import a deployed contract in your project + + + + + + + ); +}; + +const importFormSchema = z.object({ + chainId: z.coerce.number(), + contractAddress: z.string().refine( + (v) => { + try { + return isAddress(v); + } catch { + return false; + } + }, + { + message: "Invalid contract address", + }, + ), +}); + +function ImportForm(props: { + teamId: string; + projectId: string; + teamSlug: string; + projectSlug: string; + client: ThirdwebClient; + type: "contract" | "asset"; + onSuccess?: () => void; + allowedContractType: "token" | "non-token"; +}) { + const router = useDashboardRouter(); + const activeChainId = useActiveWalletChain()?.id; + + const form = useForm({ + resolver: zodResolver(importFormSchema), + values: { + chainId: activeChainId || 1, + contractAddress: "", + }, + }); + const chainSlug = useChainSlug(form.watch("chainId")); + const addContractToProject = useAddContractToProject(); + + const [notAllowedError, setNotAllowedError] = useState(false); + + return ( +
+ { + setNotAllowedError(false); + }} + onSubmit={form.handleSubmit(async (data) => { + const { chainId } = data; + let contractAddress: string; + + try { + contractAddress = getAddress(data.contractAddress); + } catch { + form.setError("contractAddress", { + message: "Invalid contract address", + }); + return; + } + + try { + const res = await fetch( + `https://contract.thirdweb.com/metadata/${chainId}/${contractAddress}`, + ); + const json = await res.json(); + + if (json.error) { + throw new Error(json.message); + } + + const hasUnknownContractName = + !!json.settings?.compilationTarget?.UnknownContract; + + const hasPartialAbi = json.metadata?.isPartialAbi; + + if (hasUnknownContractName || hasPartialAbi) { + form.setError("contractAddress", { + message: + "This contract cannot be imported since it's not verified on any block explorers.", + }); + return; + } + + // check if the contract matches the requirement + const contract = getContract({ + address: contractAddress, + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(chainId), + client: props.client, + }); + + const functionSelectors = await resolveFunctionSelectors(contract); + const ercs = supportedERCs(functionSelectors); + + const isToken = ercs.isERC20 || ercs.isERC721 || ercs.isERC1155; + + if ( + (props.allowedContractType === "token" && !isToken) || + (props.allowedContractType === "non-token" && isToken) + ) { + setNotAllowedError(true); + return; + } + + addContractToProject.mutate( + { + chainId: chainId.toString(), + contractAddress, + contractType: undefined, + deploymentType: props.type === "contract" ? undefined : "asset", + projectId: props.projectId, + teamId: props.teamId, + }, + { + onError: (err) => { + console.error(err); + if (err.message.includes("PROJECT_CONTRACT_ALREADY_EXISTS")) { + toast.error("Contract is already added to the project"); + } else { + toast.error("Failed to import contract"); + } + }, + onSuccess: () => { + revalidateCacheTagAction( + projectContractsCacheTag({ + teamId: props.teamId, + projectId: props.projectId, + }), + ); + router.refresh(); + toast.success("Contract imported successfully"); + props.onSuccess?.(); + }, + }, + ); + } catch (err) { + toast.error("Failed to import contract"); + console.error(err); + } + })} + > +
+ ( + + Contract Address + + + + + + )} + /> + +
+
+ + form.setValue("chainId", v)} + side="top" + /> +
+
+ + {notAllowedError && ( +
+ + + Invalid Contract + + {props.allowedContractType === "token" && ( + + This contract is not a token contract.
Only ERC20, + ERC721 and ERC1155 contracts can be imported as tokens. +
+ )} + {props.allowedContractType === "non-token" && ( + + This contract is a token contract.
Go to the{" "} + + Tokens + {" "} + page to import in dashboard. +
+ )} +
+
+
+ )} + +
+ {addContractToProject.isSuccess && + addContractToProject.data?.result ? ( + + ) : ( + + )} +
+ + + ); +} diff --git a/apps/dashboard/src/@/components/contracts/import-contract/types.ts b/apps/dashboard/src/@/components/contracts/import-contract/types.ts new file mode 100644 index 00000000000..8eacffc2f36 --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/import-contract/types.ts @@ -0,0 +1,15 @@ +import type { Project } from "@/api/project/projects"; +import type { Team } from "@/api/team/get-team"; + +export type MinimalTeam = Pick; +export type MinimalProject = Pick; + +export type TeamAndProjectSelection = { + team: MinimalTeam | undefined; + project: MinimalProject | undefined; +}; + +export type MinimalTeamsAndProjects = Array<{ + team: MinimalTeam; + projects: MinimalProject[]; +}>; diff --git a/apps/dashboard/src/@/components/contracts/properties.shared.tsx b/apps/dashboard/src/@/components/contracts/properties.shared.tsx new file mode 100644 index 00000000000..f1e470a3583 --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/properties.shared.tsx @@ -0,0 +1,183 @@ +import { PlusIcon, TrashIcon, XIcon } from "lucide-react"; +import { useEffect } from "react"; +import { + type ArrayPath, + type Control, + type FieldErrors, + type FieldValues, + type Path, + type PathValue, + type UseFormRegister, + type UseFormSetValue, + type UseFormWatch, + useFieldArray, + type WatchObserver, +} from "react-hook-form"; +import type { ThirdwebClient } from "thirdweb"; +import { Button } from "@/components/ui/button"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +type OptionalPropertiesInput = { + [key: string]: string | number; +}; + +interface IPropertyFieldValues extends FieldValues { + attributes?: OptionalPropertiesInput; +} + +interface IPropertiesFormControlProps< + TFieldValues extends IPropertyFieldValues, +> { + control: Control; + watch: UseFormWatch; + register: UseFormRegister; + errors: FieldErrors; + setValue: UseFormSetValue; + client: ThirdwebClient; +} + +export function PropertiesFormControl< + TFieldValues extends IPropertyFieldValues, +>({ + control, + watch, + errors, + setValue, +}: React.PropsWithChildren>) { + const { fields, append, remove } = useFieldArray({ + control, + name: "attributes" as ArrayPath, + }); + + // TODO: do we need this? + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (fields.length === 0) { + // biome-ignore lint/suspicious/noExplicitAny: FIXME + append({ trait_type: "", value: "" } as any, { shouldFocus: false }); + } + }, [fields, append]); + + return ( +
+ Attributes + {fields.map((field, index) => { + // biome-ignore lint/suspicious/noExplicitAny: FIXME + const keyError = (errors as any)?.attributes?.[index]?.trait_type + ?.message as string | undefined; + // biome-ignore lint/suspicious/noExplicitAny: FIXME + const valueError = (errors as any)?.attributes?.[index]?.value + ?.message as string | undefined; + const _isInvalid = !!(keyError || valueError); + + return ( +
+
+ } + render={({ field: traitField }) => ( + + + + + {keyError && {keyError}} + + )} + /> + + } + render={({ field: valueField }) => ( + + + {watch( + `attributes.${index}.value` as unknown as WatchObserver, + ) instanceof File ? ( +
+ , + ).name + } + className="pr-10 bg-card" + /> + +
+ ) : ( +
+ +
+ )} +
+ {valueError && {valueError}} +
+ )} + /> +
+ +
+ ); + })} +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/@3rdweb-sdk/react/components/roles/admin-only.tsx b/apps/dashboard/src/@/components/contracts/roles/admin-only.tsx similarity index 84% rename from apps/dashboard/src/@3rdweb-sdk/react/components/roles/admin-only.tsx rename to apps/dashboard/src/@/components/contracts/roles/admin-only.tsx index 24df7a058e0..c15e79f7dd9 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/components/roles/admin-only.tsx +++ b/apps/dashboard/src/@/components/contracts/roles/admin-only.tsx @@ -1,11 +1,7 @@ -import { - useIsAdmin, - useIsAdminOrSelf, -} from "@3rdweb-sdk/react/hooks/useContractRoles"; -import type { ThirdwebContract } from "thirdweb"; -import type { ComponentWithChildren } from "types/component-with-children"; - import type { JSX } from "react"; +import type { ThirdwebContract } from "thirdweb"; +import { useIsAdmin, useIsAdminOrSelf } from "@/hooks/useContractRoles"; +import type { ComponentWithChildren } from "@/types/component-with-children"; interface AdminOnlyProps { contract: ThirdwebContract; diff --git a/apps/dashboard/src/@/components/contracts/roles/lister-only.tsx b/apps/dashboard/src/@/components/contracts/roles/lister-only.tsx new file mode 100644 index 00000000000..aba09d9debd --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/roles/lister-only.tsx @@ -0,0 +1,18 @@ +import type { ThirdwebContract } from "thirdweb"; +import { useIsLister } from "@/hooks/useContractRoles"; +import type { ComponentWithChildren } from "@/types/component-with-children"; + +interface IListerOnlyProps { + contract: ThirdwebContract; +} + +export const ListerOnly: ComponentWithChildren = ({ + children, + contract, +}) => { + const isLister = useIsLister(contract); + if (!isLister) { + return null; + } + return <>{children}; +}; diff --git a/apps/dashboard/src/@/components/contracts/roles/minter-only.tsx b/apps/dashboard/src/@/components/contracts/roles/minter-only.tsx new file mode 100644 index 00000000000..2f4631e2203 --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/roles/minter-only.tsx @@ -0,0 +1,18 @@ +import type { ThirdwebContract } from "thirdweb"; +import { useIsMinter } from "@/hooks/useContractRoles"; +import type { ComponentWithChildren } from "@/types/component-with-children"; + +interface IMinterOnlyProps { + contract: ThirdwebContract; +} + +export const MinterOnly: ComponentWithChildren = ({ + children, + contract, +}) => { + const isMinter = useIsMinter(contract); + if (!isMinter) { + return null; + } + return <>{children}; +}; diff --git a/apps/dashboard/src/@/components/contracts/sources/sources-accordion.tsx b/apps/dashboard/src/@/components/contracts/sources/sources-accordion.tsx new file mode 100644 index 00000000000..f06d7f1dfcf --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/sources/sources-accordion.tsx @@ -0,0 +1,66 @@ +import type { Abi } from "abitype"; +import type { SourceFile } from "@/components/contract-components/types"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { CodeClient } from "@/components/ui/code/code.client"; + +export function SourcesAccordion({ + sources, + abi, +}: { + sources: SourceFile[]; + abi?: Abi; +}) { + return ( + + {abi && ( + + )} + + {sources.map((signature, i) => ( + + ))} + + ); +} + +function SourceAccordionItem(props: { + filename: string; + code: string; + accordionId: string; + lang: "solidity" | "json"; +}) { + return ( + + + {props.filename} + + + + + + ); +} diff --git a/apps/dashboard/src/@/components/contracts/sources/sources-panel.tsx b/apps/dashboard/src/@/components/contracts/sources/sources-panel.tsx new file mode 100644 index 00000000000..0c53e3573ae --- /dev/null +++ b/apps/dashboard/src/@/components/contracts/sources/sources-panel.tsx @@ -0,0 +1,32 @@ +import type { Abi } from "abitype"; +import type { SourceFile } from "@/components/contract-components/types"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { SourcesAccordion } from "./sources-accordion"; + +interface SourcesPanelProps { + sources?: SourceFile[]; + abi?: Abi; +} + +export const SourcesPanel: React.FC = ({ sources, abi }) => { + return ( +
+
+ {sources && sources?.length > 0 ? ( + + ) : ( +

+ Contract source code not available. Try deploying with{" "} + + thirdweb CLI v0.5+ + +

+ )} +
+
+ ); +}; diff --git a/apps/dashboard/src/@/components/footers/app-footer.tsx b/apps/dashboard/src/@/components/footers/app-footer.tsx new file mode 100644 index 00000000000..89e1fbc2ada --- /dev/null +++ b/apps/dashboard/src/@/components/footers/app-footer.tsx @@ -0,0 +1,162 @@ +import { ThirdwebMiniLogo } from "app/(app)/components/ThirdwebMiniLogo"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { GithubIcon } from "@/icons/brand-icons/GithubIcon"; +import { InstagramIcon } from "@/icons/brand-icons/InstagramIcon"; +import { LinkedInIcon } from "@/icons/brand-icons/LinkedinIcon"; +import { RedditIcon } from "@/icons/brand-icons/RedditIcon"; +import { TiktokIcon } from "@/icons/brand-icons/TiktokIcon"; +import { XIcon } from "@/icons/brand-icons/XIcon"; +import { YoutubeIcon } from "@/icons/brand-icons/YoutubeIcon"; +import { cn } from "@/lib/utils"; + +type AppFooterProps = { + className?: string; + containerClassName?: string; +}; + +const footerLinks = [ + { + href: "/home", + label: "Home", + }, + { + href: "https://blog.thirdweb.com", + label: "Blog", + }, + { + href: "https://portal.thirdweb.com/changelog", + label: "Changelog", + }, + { + href: "https://feedback.thirdweb.com/", + label: "Feedback", + }, + { + href: "https://thirdweb.com/privacy-policy", + label: "Privacy Policy", + }, + { + href: "https://thirdweb.com/terms", + label: "Terms of Service", + }, + { + href: "https://thirdweb.com/chainlist", + label: "Chainlist", + }, +]; + +export function AppFooter(props: AppFooterProps) { + return ( +
+
+ {/* top row */} +
+
+ +

+ © {new Date().getFullYear()} thirdweb +

+
+
+ + + + + + + +
+
+ {/* bottom row */} +
+ {footerLinks.map((link) => ( + + ))} +
+
+
+ ); +} + +function FooterLink(props: { + href: string; + label: string; + prefetch?: boolean; +}) { + return ( + + {props.label} + + ); +} diff --git a/apps/dashboard/src/@/components/in-app-wallet-users-content/AdvancedSearchInput.tsx b/apps/dashboard/src/@/components/in-app-wallet-users-content/AdvancedSearchInput.tsx new file mode 100644 index 00000000000..b037d66e1ac --- /dev/null +++ b/apps/dashboard/src/@/components/in-app-wallet-users-content/AdvancedSearchInput.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { SearchIcon, XIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/Spinner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { SearchType } from "./types"; + +const searchTypeLabels: Record = { + email: "Email", + phone: "Phone", + id: "Auth Identifier", + address: "Address", + externalWallet: "External Wallet", + userId: "User Identifier", +}; + +export function AdvancedSearchInput(props: { + onSearch: (searchType: SearchType, query: string) => void; + onClear: () => void; + isLoading: boolean; + hasResults: boolean; +}) { + const [searchType, setSearchType] = useState("email"); + const [query, setQuery] = useState(""); + + const handleSearch = () => { + if (query.trim()) { + props.onSearch(searchType, query.trim()); + } + }; + + const handleClear = () => { + setQuery(""); + props.onClear(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + return ( +
+ + +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> + + + {props.hasResults && ( +
+ +
+ )} +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/in-app-wallet-users-content/SearchResults.tsx b/apps/dashboard/src/@/components/in-app-wallet-users-content/SearchResults.tsx new file mode 100644 index 00000000000..ec1c664a197 --- /dev/null +++ b/apps/dashboard/src/@/components/in-app-wallet-users-content/SearchResults.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { format } from "date-fns"; +import type { ThirdwebClient } from "thirdweb"; +import type { WalletUser } from "thirdweb/wallets"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CopyTextButton } from "../ui/CopyTextButton"; + +const getAuthIdentifier = (user: WalletUser) => { + const mainDetail = user.linkedAccounts[0]?.details; + return ( + mainDetail?.id ?? + mainDetail?.email ?? + mainDetail?.phone ?? + mainDetail?.address + ); +}; + +const truncateIdentifier = (value: string) => { + if (value.length <= 18) { + return value; + } + return `${value.slice(0, 8)}...${value.slice(-4)}`; +}; + +export function SearchResults(props: { + results: WalletUser[]; + client: ThirdwebClient; +}) { + if (props.results.length === 0) { + return ( + + +
+

No users found

+

+ Try searching with different criteria +

+
+
+
+ ); + } + + return ( +
+ {props.results.map((user) => { + const walletAddress = user.wallets?.[0]?.address; + const createdAt = user.wallets?.[0]?.createdAt; + const mainDetail = user.linkedAccounts?.[0]?.details; + const email = mainDetail?.email as string | undefined; + const phone = mainDetail?.phone as string | undefined; + const authIdentifier = getAuthIdentifier(user); + const userIdentifier = user.id; + + // Get external wallet addresses from linkedAccounts where type is 'siwe' + const externalWalletAccounts = + user.linkedAccounts?.filter((account) => account.type === "siwe") || + []; + + return ( + + + User Details + + +
+
+

+ User Identifier +

+ {userIdentifier ? ( + + ) : ( +

N/A

+ )} +
+ +
+

+ Auth Identifier +

+ {authIdentifier ? ( + + ) : ( +

N/A

+ )} +
+ + {walletAddress && ( +
+

+ Wallet Address +

+ +
+ )} + + {email && ( +
+

+ Email +

+

{email}

+
+ )} + + {phone && ( +
+

+ Phone +

+

{phone}

+
+ )} + + {externalWalletAccounts.length > 0 && ( +
+

+ External Wallets +

+
+ {externalWalletAccounts.map((account, index) => { + const address = account.details?.address as + | string + | undefined; + return address ? ( +
+ +
+ ) : null; + })} +
+
+ )} + + {createdAt && ( +
+

+ Created +

+

+ {format(new Date(createdAt), "MMM dd, yyyy")} +

+
+ )} + +
+

+ Login Methods +

+
+ {user.linkedAccounts?.map((account, index) => ( + + + + + {account.type} + + + +
+ {Object.entries(account.details).map( + ([key, value]) => ( +
+ {key}:{" "} + {String(value)} +
+ ), + )} +
+
+
+
+ ))} +
+
+
+
+
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/@/components/in-app-wallet-users-content/searchUsers.ts b/apps/dashboard/src/@/components/in-app-wallet-users-content/searchUsers.ts new file mode 100644 index 00000000000..918016a200a --- /dev/null +++ b/apps/dashboard/src/@/components/in-app-wallet-users-content/searchUsers.ts @@ -0,0 +1,141 @@ +import type { + ListUserWalletsData, + ListUserWalletsResponses, +} from "@thirdweb-dev/api"; +import { configure, listUserWallets } from "@thirdweb-dev/api"; +import type { WalletUser } from "thirdweb/wallets"; +import { THIRDWEB_API_HOST } from "@/constants/urls"; +import type { SearchType } from "./types"; + +// Configure the API client to use the correct base URL +configure({ + override: { + baseUrl: THIRDWEB_API_HOST, + }, +}); + +// Extract types from the generated API +type APIWallet = ListUserWalletsResponses[200]["result"]["wallets"][0]; +type APIProfile = APIWallet["profiles"][0]; + +// Transform API response to match wallet user format +function transformToWalletUser(apiWallet: APIWallet): WalletUser { + return { + id: apiWallet.userId || getProfileId(apiWallet.profiles[0]) || "", + linkedAccounts: apiWallet.profiles.map((profile) => { + // Create details object based on the profile data + let details: + | { email: string; [key: string]: string } + | { phone: string; [key: string]: string } + | { address: string; [key: string]: string } + | { id: string; [key: string]: string }; + + const profileId = getProfileId(profile); + + if ("email" in profile && profile.email) { + details = { email: profile.email, id: profileId }; + } else if ("phone" in profile && profile.phone) { + details = { phone: profile.phone, id: profileId }; + } else if ("walletAddress" in profile && profile.walletAddress) { + details = { address: profile.walletAddress, id: profileId }; + } else { + details = { id: profileId }; + } + + return { + type: profile.type, + details, + }; + }), + wallets: apiWallet.address + ? [ + { + address: apiWallet.address, + createdAt: apiWallet.createdAt || new Date().toISOString(), + type: "enclave" as const, + }, + ] + : [], + }; +} + +// Helper function to safely get ID from any profile type +function getProfileId(profile: APIProfile | undefined): string { + if (!profile) return ""; + + if ("id" in profile) { + return profile.id; + } else if ("credentialId" in profile) { + return profile.credentialId; + } else if ("identifier" in profile) { + return profile.identifier; + } + + return ""; +} + +export async function searchUsers( + authToken: string, + clientId: string | undefined, + ecosystemSlug: string | undefined, + teamId: string, + searchType: SearchType, + query: string, +): Promise { + try { + // Prepare query parameters + const queryParams: ListUserWalletsData["query"] = { + limit: 50, + }; + + // Add search parameter based on search type + switch (searchType) { + case "email": + queryParams.email = query; + break; + case "phone": + queryParams.phone = query; + break; + case "id": + queryParams.id = query; + break; + case "address": + queryParams.address = query; + break; + case "externalWallet": + queryParams.externalWalletAddress = query; + break; + case "userId": + queryParams.userId = query; + break; + } + + // Use the generated API function with Bearer authentication + const response = await listUserWallets({ + query: queryParams, + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-thirdweb-team-id": teamId, + ...(clientId && { "x-client-id": clientId }), + ...(ecosystemSlug && { + "x-ecosystem-id": `ecosystem.${ecosystemSlug}`, + }), + }, + }); + + // Handle response + if (response.error || !response.data) { + console.error( + "Error searching users:", + response.error || "No data returned", + ); + return []; + } + + return response.data.result.wallets.map(transformToWalletUser); + } catch (error) { + console.error("Error searching users:", error); + return []; + } +} diff --git a/apps/dashboard/src/@/components/in-app-wallet-users-content/types.ts b/apps/dashboard/src/@/components/in-app-wallet-users-content/types.ts new file mode 100644 index 00000000000..d30927c0880 --- /dev/null +++ b/apps/dashboard/src/@/components/in-app-wallet-users-content/types.ts @@ -0,0 +1,7 @@ +export type SearchType = + | "email" + | "phone" + | "id" + | "address" + | "externalWallet" + | "userId"; diff --git a/apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx b/apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx new file mode 100644 index 00000000000..676e3b3e1d1 --- /dev/null +++ b/apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx @@ -0,0 +1,656 @@ +"use client"; + +import { createColumnHelper } from "@tanstack/react-table"; +import { format } from "date-fns"; +import { + ArrowLeftIcon, + ArrowRightIcon, + DollarSignIcon, + RefreshCwIcon, + UserIcon, + WalletIcon, +} from "lucide-react"; +import Papa from "papaparse"; +import { useCallback, useMemo, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import type { WalletUser } from "thirdweb/wallets"; +import { StatCard } from "@/components/analytics/stat"; +import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { TWTable } from "@/components/blocks/TWTable"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Spinner } from "@/components/ui/Spinner"; +import { + ToolTipLabel, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + useAllEmbeddedWallets, + useEmbeddedWallets, +} from "@/hooks/useEmbeddedWallets"; +import { + useFetchAllPortfolios, + type WalletPortfolioData, +} from "@/hooks/useWalletPortfolio"; +import { CopyTextButton } from "../ui/CopyTextButton"; +import { AdvancedSearchInput } from "./AdvancedSearchInput"; +import { SearchResults } from "./SearchResults"; +import { searchUsers } from "./searchUsers"; +import type { SearchType } from "./types"; + +const getAuthIdentifier = (accounts: WalletUser["linkedAccounts"]) => { + const mainDetail = accounts[0]?.details; + return ( + mainDetail?.id ?? + mainDetail?.email ?? + mainDetail?.phone ?? + mainDetail?.address + ); +}; + +const getPrimaryEmail = (accounts: WalletUser["linkedAccounts"]) => { + const emailFromPrimary = accounts[0]?.details?.email; + if (emailFromPrimary) { + return emailFromPrimary; + } + + const emailAccount = accounts.find((account) => { + return typeof account.details?.email === "string" && account.details.email; + }); + + if (emailAccount && typeof emailAccount.details.email === "string") { + return emailAccount.details.email; + } + + return undefined; +}; + +const columnHelper = createColumnHelper(); + +const truncateIdentifier = (value: string) => { + if (value.length <= 18) { + return value; + } + return `${value.slice(0, 8)}...${value.slice(-4)}`; +}; + +export function UserWalletsTable( + props: { + authToken: string; + client: ThirdwebClient; + teamId: string; + } & ( + | { projectClientId: string; ecosystemSlug?: never } + | { ecosystemSlug: string; projectClientId?: never } + ), +) { + const [activePage, setActivePage] = useState(1); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [hasSearchResults, setHasSearchResults] = useState(false); + + // Portfolio state + const [selectedChains, setSelectedChains] = useState([1]); // Default to Ethereum + const [portfolioMap, setPortfolioMap] = useState< + Map + >(new Map()); + const [portfolioLoaded, setPortfolioLoaded] = useState(false); + const [fetchProgress, setFetchProgress] = useState({ + completed: 0, + total: 0, + }); + const [csvExportProgress, setCsvExportProgress] = useState<{ + fetchedWallets: number; + totalWallets?: number; + }>(); + + const walletsQuery = useEmbeddedWallets({ + authToken: props.authToken, + clientId: props.projectClientId, + ecosystemSlug: props.ecosystemSlug, + teamId: props.teamId, + page: activePage, + }); + const wallets = walletsQuery?.data?.users || []; + const { + mutateAsync: getAllEmbeddedWalletsForBalances, + isPending: isLoadingWalletsForBalances, + } = useAllEmbeddedWallets({ + authToken: props.authToken, + }); + const { + mutateAsync: getAllEmbeddedWalletsForCsv, + isPending: isLoadingWalletsForCsv, + } = useAllEmbeddedWallets({ + authToken: props.authToken, + }); + + const fetchPortfoliosMutation = useFetchAllPortfolios(); + + const handleFetchBalances = useCallback(async () => { + if (selectedChains.length === 0) return; + + try { + // First get all wallets + const allWallets = await getAllEmbeddedWalletsForBalances({ + clientId: props.projectClientId, + ecosystemSlug: props.ecosystemSlug, + teamId: props.teamId, + }); + + const allAddresses = allWallets + .map((w) => w.wallets[0]?.address) + .filter((a): a is string => !!a); + + if (allAddresses.length === 0) { + setPortfolioLoaded(true); + return; + } + + setFetchProgress({ completed: 0, total: allAddresses.length }); + + const results = await fetchPortfoliosMutation.mutateAsync({ + addresses: allAddresses, + chainIds: selectedChains, + authToken: props.authToken, + teamId: props.teamId, + clientId: props.projectClientId, + ecosystemSlug: props.ecosystemSlug, + onProgress: (completed, total) => { + setFetchProgress({ completed, total }); + }, + }); + + setPortfolioMap(results); + setPortfolioLoaded(true); + } catch (error) { + console.error("Failed to fetch balances:", error); + } + }, [ + selectedChains, + getAllEmbeddedWalletsForBalances, + props.projectClientId, + props.ecosystemSlug, + props.teamId, + props.authToken, + fetchPortfoliosMutation, + ]); + + const isFetchingBalances = + isLoadingWalletsForBalances || fetchPortfoliosMutation.isPending; + + const csvExportLabel = useMemo(() => { + if (!isLoadingWalletsForCsv) { + return "Download as .csv"; + } + + const fetchedWallets = csvExportProgress?.fetchedWallets ?? 0; + const totalWallets = csvExportProgress?.totalWallets; + + if (fetchedWallets === 0) { + return "Preparing export..."; + } + + if (totalWallets && totalWallets > 0) { + const cappedFetchedWallets = Math.min(fetchedWallets, totalWallets); + const percentage = Math.min( + 100, + Math.round((cappedFetchedWallets / totalWallets) * 100), + ); + + return `Exporting ${percentage}% (${cappedFetchedWallets.toLocaleString()}/${totalWallets.toLocaleString()})`; + } + + return `Exporting ${fetchedWallets.toLocaleString()} users...`; + }, [csvExportProgress, isLoadingWalletsForCsv]); + + const aggregatedStats = useMemo(() => { + let fundedWallets = 0; + let totalValue = 0; + portfolioMap.forEach((data) => { + if (data.totalUsdValue > 0) { + fundedWallets++; + totalValue += data.totalUsdValue; + } + }); + return { fundedWallets, totalValue }; + }, [portfolioMap]); + + const columns = useMemo(() => { + return [ + columnHelper.accessor("id", { + cell: (cell) => { + const userId = cell.getValue(); + + if (!userId) { + return "N/A"; + } + + return ( + + ); + }, + header: "User Identifier", + id: "user_identifier", + }), + columnHelper.accessor("linkedAccounts", { + cell: (cell) => { + const identifier = getAuthIdentifier(cell.getValue()); + + if (!identifier) { + return "N/A"; + } + + return ( + + ); + }, + enableColumnFilter: true, + header: "Auth Identifier", + id: "auth_identifier", + }), + columnHelper.accessor("wallets", { + cell: (cell) => { + const address = cell.getValue()[0]?.address; + return address ? ( + + ) : null; + }, + header: "Address", + id: "address", + }), + columnHelper.accessor("wallets", { + id: "total_balance", + header: "Total Balance", + cell: (cell) => { + const address = cell.getValue()[0]?.address; + if (!address) return "N/A"; + if (!portfolioLoaded) { + return ; + } + const data = portfolioMap.get(address); + if (!data) { + return ; + } + return ( + + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(data.totalUsdValue)} + + ); + }, + }), + columnHelper.accessor("wallets", { + id: "tokens", + header: "Tokens", + cell: (cell) => { + const address = cell.getValue()[0]?.address; + if (!address) return "N/A"; + if (!portfolioLoaded) { + return ; + } + const data = portfolioMap.get(address); + if (!data || data.tokens.length === 0) { + return None; + } + + const topTokens = data.tokens + .sort((a, b) => (b.usdValue || 0) - (a.usdValue || 0)) + .slice(0, 3) + .map((t) => t.symbol) + .join(", "); + + return ( + + + + {topTokens} + {data.tokens.length > 3 ? "..." : ""} + + +
+ {data.tokens.map((t) => ( +
+ {t.symbol} + + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(t.usdValue || 0)} + +
+ ))} +
+
+
+
+ ); + }, + }), + columnHelper.accessor("linkedAccounts", { + cell: (cell) => { + const email = getPrimaryEmail(cell.getValue()); + + if (!email) { + return N/A; + } + + return ( + + ); + }, + header: "Email", + id: "email", + }), + columnHelper.accessor("wallets", { + cell: (cell) => { + const value = cell.getValue()[0]?.createdAt; + + if (!value) { + return; + } + return ( + + + {format(new Date(value), "MMM dd, yyyy")} + + + ); + }, + header: "Created", + id: "created_at", + }), + columnHelper.accessor("linkedAccounts", { + cell: (cell) => { + const value = cell.getValue(); + const loginMethodsDisplay = value.reduce((acc, curr) => { + if (acc.length === 2) { + acc.push("..."); + } + if (acc.length < 2) { + acc.push(curr.type); + } + return acc; + }, [] as string[]); + const loginMethods = value.map((v) => v.type).join(", "); + return ( + + + + {loginMethodsDisplay.join(", ")} + + + {loginMethods} + + + + ); + }, + header: "Login Methods", + id: "login_methods", + }), + ]; + }, [props.client, portfolioMap, portfolioLoaded]); + + const handleSearch = async (searchType: SearchType, query: string) => { + setIsSearching(true); + try { + const results = await searchUsers( + props.authToken, + props.projectClientId, + props.ecosystemSlug, + props.teamId, + searchType, + query, + ); + setSearchResults(results); + setHasSearchResults(true); + } catch (error) { + console.error("Search failed:", error); + setSearchResults([]); + setHasSearchResults(true); + } finally { + setIsSearching(false); + } + }; + + const handleClearSearch = () => { + setSearchResults([]); + setHasSearchResults(false); + }; + + const downloadCSV = useCallback(async () => { + if (wallets.length === 0) { + return; + } + setCsvExportProgress({ fetchedWallets: 0 }); + + try { + const usersWallets = await getAllEmbeddedWalletsForCsv({ + clientId: props.projectClientId, + ecosystemSlug: props.ecosystemSlug, + teamId: props.teamId, + onProgress: setCsvExportProgress, + }); + const csv = Papa.unparse( + usersWallets.map((row) => { + const email = getPrimaryEmail(row.linkedAccounts); + + return { + address: row.wallets[0]?.address || "Uninitialized", + created: row.wallets[0]?.createdAt + ? new Date(row.wallets[0].createdAt).toISOString() + : "Wallet not created yet", + email: email || "N/A", + login_methods: row.linkedAccounts.map((acc) => acc.type).join(", "), + auth_identifier: getAuthIdentifier(row.linkedAccounts) || "N/A", + user_identifier: row.id || "N/A", + }; + }), + ); + const csvUrl = URL.createObjectURL( + new Blob([csv], { type: "text/csv;charset=utf-8;" }), + ); + const tempLink = document.createElement("a"); + tempLink.href = csvUrl; + tempLink.setAttribute("download", "download.csv"); + tempLink.click(); + } finally { + setCsvExportProgress(undefined); + } + }, [ + wallets, + props.projectClientId, + getAllEmbeddedWalletsForCsv, + props.teamId, + props.ecosystemSlug, + ]); + + return ( +
+
+
+
+
+ +
+
+

+ User Wallets +

+

+ View and manage your project's users +

+
+ +
+
+ +
+ +
+
+ +
+ {hasSearchResults ? ( + + ) : ( + <> + {/* Chain Selector and Fetch Button */} +
+
+ + +
+ + {isFetchingBalances && ( +
+ {fetchProgress.total > 0 && ( + + )} +

+ This may take a few minutes +

+
+ )} + + {portfolioLoaded && !isFetchingBalances && ( + + Balances loaded for {portfolioMap.size} wallets + + )} +
+ + {/* Stats Section */} +
+ + + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value) + } + isPending={isFetchingBalances} + emptyText={!portfolioLoaded ? "—" : undefined} + /> +
+ + +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx b/apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx new file mode 100644 index 00000000000..59c80e43aa1 --- /dev/null +++ b/apps/dashboard/src/@/components/misc/AnnouncementBanner.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { XIcon } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; + +function AnnouncementBannerUI(props: { + href: string; + label: string; + trackingLabel: string; +}) { + const [hasDismissedAnnouncement, setHasDismissedAnnouncement] = + useLocalStorage(`dismissed-${props.trackingLabel}`, false, true); + + if (hasDismissedAnnouncement) { + return null; + } + + return ( +
+ + + {props.label} + + + + +
+ ); +} + +export function AnnouncementBanner() { + return ( + + ); +} diff --git a/apps/dashboard/src/components/selects/CustomChainRenderer.tsx b/apps/dashboard/src/@/components/misc/CustomChainRenderer.tsx similarity index 78% rename from apps/dashboard/src/components/selects/CustomChainRenderer.tsx rename to apps/dashboard/src/@/components/misc/CustomChainRenderer.tsx index 629d68828c6..ca63f7bfb66 100644 --- a/apps/dashboard/src/components/selects/CustomChainRenderer.tsx +++ b/apps/dashboard/src/@/components/misc/CustomChainRenderer.tsx @@ -1,15 +1,12 @@ -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { ChainIcon } from "components/icons/ChainIcon"; -import { OPSponsoredChains } from "constants/chains"; import { SettingsIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; import type { UseNetworkSwitcherModalOptions } from "thirdweb/react"; -import { useAllChainsData } from "../../hooks/chains/allChains"; -import { - type StoredChain, - addRecentlyUsedChainId, -} from "../../stores/chainStores"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/Spinner"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { cn } from "@/lib/utils"; +import { addRecentlyUsedChainId, type StoredChain } from "@/stores/chainStores"; type ChainRenderProps = React.ComponentProps< NonNullable @@ -18,6 +15,7 @@ type ChainRenderProps = React.ComponentProps< type CustomChainRendererProps = ChainRenderProps & { disableChainConfig?: boolean; openEditChainModal: (chain: StoredChain) => void; + client: ThirdwebClient; }; export const CustomChainRenderer = ({ @@ -28,16 +26,17 @@ export const CustomChainRenderer = ({ close, disableChainConfig, openEditChainModal, + client, }: CustomChainRendererProps) => { const { idToChain } = useAllChainsData(); const storedChain = idToChain.get(chain.id); const isDeprecated = storedChain?.status === "deprecated"; - const isSponsored = OPSponsoredChains.includes(chain.id); return (
{/* biome-ignore lint/a11y/useKeyWithClickEvents: FIXME */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: FIXME */}
- +

)} - {isSponsored && ( -

- Sponsored -
- )}
{switching && (
@@ -93,9 +86,8 @@ export const CustomChainRenderer = ({ {!disableChainConfig && storedChain && ( diff --git a/apps/dashboard/src/@/components/misc/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginClient.tsx b/apps/dashboard/src/@/components/misc/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginClient.tsx new file mode 100644 index 00000000000..b46437b63f8 --- /dev/null +++ b/apps/dashboard/src/@/components/misc/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginClient.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { useActiveAccount } from "thirdweb/react"; +import { checksumAddress } from "thirdweb/utils"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; + +export function EnsureValidConnectedWalletLoginClient(props: { + loggedInAddress: string; +}) { + const router = useDashboardRouter(); + const connectedAddress = useActiveAccount()?.address; + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!connectedAddress) { + return; + } + + if ( + checksumAddress(connectedAddress) !== + checksumAddress(props.loggedInAddress) + ) { + const currentHref = new URL(window.location.href); + const currentPathname = currentHref.pathname; + const currentSearchParams = currentHref.searchParams.toString(); + router.replace( + buildLoginPath( + `${currentPathname}${currentSearchParams ? `?${currentSearchParams}` : ""}`, + ), + ); + } + }, [connectedAddress, props.loggedInAddress, router]); + + return null; +} + +function buildLoginPath(pathname: string | undefined): string { + return `/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`; +} diff --git a/apps/dashboard/src/@/components/misc/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer.tsx b/apps/dashboard/src/@/components/misc/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer.tsx new file mode 100644 index 00000000000..316098bd374 --- /dev/null +++ b/apps/dashboard/src/@/components/misc/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer.tsx @@ -0,0 +1,15 @@ +import { getAuthTokenWalletAddress } from "@/api/auth-token"; +import { EnsureValidConnectedWalletLoginClient } from "./EnsureValidConnectedWalletLoginClient"; + +// ensure that address in backend matches connected wallet address +// if there's a mismatch - redirect to login page + +export async function EnsureValidConnectedWalletLoginServer() { + const address = await getAuthTokenWalletAddress(); + + if (address) { + return ; + } + + return null; +} diff --git a/apps/dashboard/src/components/selects/NetworkSelectorButton.tsx b/apps/dashboard/src/@/components/misc/NetworkSelectorButton.tsx similarity index 86% rename from apps/dashboard/src/components/selects/NetworkSelectorButton.tsx rename to apps/dashboard/src/@/components/misc/NetworkSelectorButton.tsx index 7b1d0b82d90..cb38e067f59 100644 --- a/apps/dashboard/src/components/selects/NetworkSelectorButton.tsx +++ b/apps/dashboard/src/@/components/misc/NetworkSelectorButton.tsx @@ -1,28 +1,27 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { useThirdwebClient } from "@/constants/thirdweb.client"; -import { useStore } from "@/lib/reactive"; -import { cn } from "@/lib/utils"; -import { popularChains } from "@3rdweb-sdk/react/components/popularChains"; -import { ChainIcon } from "components/icons/ChainIcon"; -import { useActiveChainAsDashboardChain } from "lib/v5-adapter"; import { ChevronDownIcon } from "lucide-react"; import { useTheme } from "next-themes"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useActiveWallet } from "thirdweb/react"; -import { useNetworkSwitcherModal } from "thirdweb/react"; -import { useFavoriteChainIds } from "../../app/(dashboard)/(chain)/components/client/star-button"; -import { getSDKTheme } from "../../app/components/sdk-component-theme"; -import { mapV4ChainToV5Chain } from "../../contexts/map-chains"; -import { useAllChainsData } from "../../hooks/chains/allChains"; +import type { ThirdwebClient } from "thirdweb"; +import { useActiveWallet, useNetworkSwitcherModal } from "thirdweb/react"; +import { Button } from "@/components/ui/button"; +import { popularChains } from "@/constants/popularChains"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { useActiveChainAsDashboardChain } from "@/hooks/chains/v5-adapter"; +import { useFavoriteChainIds } from "@/hooks/favorite-chains"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { useStore } from "@/lib/reactive"; +import { cn } from "@/lib/utils"; import { - type StoredChain, addRecentlyUsedChainId, recentlyUsedChainIdsStore, -} from "../../stores/chainStores"; -import { LazyConfigureNetworkModal } from "../configure-networks/LazyConfigureNetworkModal"; + type StoredChain, +} from "@/stores/chainStores"; +import { mapV4ChainToV5Chain } from "../../utils/map-chains"; +import { getSDKTheme } from "../../utils/sdk-component-theme"; import { CustomChainRenderer } from "./CustomChainRenderer"; +import { LazyConfigureNetworkModal } from "./configure-networks/LazyConfigureNetworkModal"; interface NetworkSelectorButtonProps { disabledChainIds?: number[]; @@ -30,6 +29,7 @@ interface NetworkSelectorButtonProps { isDisabled?: boolean; onSwitchChain?: (chain: StoredChain) => void; className?: string; + client: ThirdwebClient; } export const NetworkSelectorButton: React.FC = ({ @@ -38,8 +38,8 @@ export const NetworkSelectorButton: React.FC = ({ isDisabled, onSwitchChain, className, + client, }) => { - const client = useThirdwebClient(); const { idToChain, allChains } = useAllChainsData(); // recently used chains @@ -122,7 +122,7 @@ export const NetworkSelectorButton: React.FC = ({ const chain = useActiveChainAsDashboardChain(); const prevChain = useRef(chain); - // handle switch network done from wallet app/extension + // handle switch network done from wallet app/(app)/extension // TODO: legitimate use-case, but maybe theres a better way to hook into this? // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -143,72 +143,78 @@ export const NetworkSelectorButton: React.FC = ({ return ( <> ); diff --git a/apps/dashboard/src/components/configure-networks/ConfigureNetworkForm.tsx b/apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworkForm.tsx similarity index 90% rename from apps/dashboard/src/components/configure-networks/ConfigureNetworkForm.tsx rename to apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworkForm.tsx index 52b618b3afe..ab6be936e6e 100644 --- a/apps/dashboard/src/components/configure-networks/ConfigureNetworkForm.tsx +++ b/apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworkForm.tsx @@ -1,20 +1,22 @@ +import { CircleAlertIcon, Trash2Icon } from "lucide-react"; +import { useId } from "react"; +import { useForm } from "react-hook-form"; +import type { ThirdwebClient } from "thirdweb"; import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; -import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { ScrollShadow } from "@/components/ui/ScrollShadow"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { ChainIconClient } from "@/icons/ChainIcon"; import { useStore } from "@/lib/reactive"; -import { ChainIcon } from "components/icons/ChainIcon"; -import { getDashboardChainRpc } from "lib/rpc"; -import { CircleAlertIcon, Trash2Icon } from "lucide-react"; -import { useForm } from "react-hook-form"; -import { useAllChainsData } from "../../hooks/chains/allChains"; +import { getDashboardChainRpc } from "@/lib/rpc"; import { - type StoredChain, chainOverridesStore, removeChainOverrides, -} from "../../stores/chainStores"; + type StoredChain, +} from "@/stores/chainStores"; import { ChainIdInput } from "./Form/ChainIdInput"; import { IconUpload } from "./Form/IconUpload"; import { NetworkIDInput } from "./Form/NetworkIdInput"; @@ -45,6 +47,7 @@ interface NetworkConfigFormProps { prefillName?: string; onCustomClick?: (name: string) => void; onSubmit: (chain: StoredChain) => void; + client: ThirdwebClient; } const maxAllowedChainOverrides = 10; @@ -54,12 +57,21 @@ export const ConfigureNetworkForm: React.FC = ({ onSubmit, prefillSlug, prefillChainId, + client, }) => { const { idToChain, nameToChain } = useAllChainsData(); const chainOverrides = useStore(chainOverridesStore); - + const currencySymbolId = useId(); + const nameId = useId(); + const networkTypeId = useId(); const form = useForm({ + mode: "onChange", values: { + chainId: editingChain?.chainId + ? `${editingChain?.chainId}` + : prefillChainId || "", + currencySymbol: editingChain?.nativeCurrency.symbol || "", + icon: editingChain?.icon?.url || "", name: editingChain?.name || prefillSlug || "", rpcUrl: (!editingChain || editingChain.status === "deprecated" @@ -67,17 +79,15 @@ export const ConfigureNetworkForm: React.FC = ({ : // if chain is custom or modified, show the rpc as is editingChain.isCustom || editingChain.isModified ? editingChain.rpc[0] - : getDashboardChainRpc(editingChain.chainId, editingChain)) || "", - chainId: editingChain?.chainId - ? `${editingChain?.chainId}` - : prefillChainId || "", - currencySymbol: editingChain?.nativeCurrency.symbol || "", - type: editingChain?.testnet ? "testnet" : "mainnet", - icon: editingChain?.icon?.url || "", + : getDashboardChainRpc( + editingChain.chainId, + editingChain, + client, + )) || "", slug: prefillSlug || editingChain?.slug || "", stackType: "", + type: editingChain?.testnet ? "testnet" : "mainnet", }, - mode: "onChange", }); const isFullyEditable = !editingChain || editingChain?.isCustom; @@ -131,52 +141,52 @@ export const ConfigureNetworkForm: React.FC = ({ if (editingChain) { configuredNetwork = { ...editingChain, - name: data.name, - rpc: [data.rpcUrl], chainId: Number.parseInt(data.chainId), - nativeCurrency: { - ...editingChain.nativeCurrency, - symbol: data.currencySymbol, - }, icon: editingChain.icon ? { ...editingChain.icon, url: data.icon, } : { + format: "", + height: 50, url: data.icon, // we don't care about these fields - adding dummy values width: 50, - height: 50, - format: "", }, - testnet: data.type === "testnet", - stackType: data.stackType || "", - }; - } else { - configuredNetwork = { name: data.name, - rpc: [data.rpcUrl], - chainId: Number.parseInt(data.chainId), nativeCurrency: { + ...editingChain.nativeCurrency, symbol: data.currencySymbol, - name: data.currencySymbol, - decimals: 18, }, + rpc: [data.rpcUrl], + stackType: data.stackType || "", testnet: data.type === "testnet", - shortName: form.watch("slug"), - slug: form.watch("slug"), + }; + } else { + configuredNetwork = { // we don't care about this field chain: "", + chainId: Number.parseInt(data.chainId), icon: data.icon ? { + format: "", + height: 50, url: data.icon, width: 50, - height: 50, - format: "", } : undefined, + name: data.name, + nativeCurrency: { + decimals: 18, + name: data.currencySymbol, + symbol: data.currencySymbol, + }, + rpc: [data.rpcUrl], + shortName: form.watch("slug"), + slug: form.watch("slug"), stackType: data.stackType || "", + testnet: data.type === "testnet", }; } @@ -231,9 +241,9 @@ export const ConfigureNetworkForm: React.FC = ({

{c.name}

@@ -259,23 +269,21 @@ export const ConfigureNetworkForm: React.FC = ({
{/* name */} { const value = e.target.value; form.setValue("name", value, { - shouldValidate: true, shouldDirty: true, + shouldValidate: true, }); if (isFullyEditable) { @@ -286,31 +294,33 @@ export const ConfigureNetworkForm: React.FC = ({ } } }} + placeholder="My Network" ref={ref} + type="text" /> {/* Slug */} - +
{/* Chain ID + Currency Symbol */}
- + {/* Currency Symbol */} @@ -320,8 +330,8 @@ export const ConfigureNetworkForm: React.FC = ({
{/* Testnet / Mainnet */} = ({ } > { form.setValue("type", v === "testnet" ? "testnet" : "mainnet", { - shouldValidate: true, shouldDirty: true, + shouldValidate: true, }); }} - className="flex gap-4" + value={form.watch("type")} >
- +
- +
@@ -363,14 +373,18 @@ export const ConfigureNetworkForm: React.FC = ({ {/* Icon */}
- + { form.setValue("icon", uri, { shouldDirty: true }); }} @@ -394,17 +408,17 @@ export const ConfigureNetworkForm: React.FC = ({ {/* Buttons */}
- {editedFrom && ( diff --git a/apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworkModal.tsx b/apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworkModal.tsx new file mode 100644 index 00000000000..3be158dda7c --- /dev/null +++ b/apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworkModal.tsx @@ -0,0 +1,37 @@ +import type { ThirdwebClient } from "thirdweb"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { addRecentlyUsedChainId, type StoredChain } from "@/stores/chainStores"; +import { ConfigureNetworks } from "./ConfigureNetworks"; + +export type ConfigureNetworkModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onNetworkAdded?: (chain: StoredChain) => void; + editChain: StoredChain | undefined; + client: ThirdwebClient; +}; + +export const ConfigureNetworkModal: React.FC = ( + props, +) => { + const onModalClose = () => { + props.onOpenChange(false); + }; + + return ( + + + { + addRecentlyUsedChainId(chain.chainId); + props.onNetworkAdded?.(chain); + onModalClose(); + }} + onNetworkConfigured={onModalClose} + /> + + + ); +}; diff --git a/apps/dashboard/src/components/configure-networks/ConfigureNetworks.tsx b/apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworks.tsx similarity index 78% rename from apps/dashboard/src/components/configure-networks/ConfigureNetworks.tsx rename to apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworks.tsx index 0084b0accff..6ca39ee6298 100644 --- a/apps/dashboard/src/components/configure-networks/ConfigureNetworks.tsx +++ b/apps/dashboard/src/@/components/misc/configure-networks/ConfigureNetworks.tsx @@ -1,33 +1,23 @@ -import { useTrack } from "hooks/analytics/useTrack"; import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { reportChainConfigurationAdded } from "@/analytics/report"; import { - type StoredChain, addChainOverrides, addRecentlyUsedChainId, -} from "../../stores/chainStores"; + type StoredChain, +} from "@/stores/chainStores"; import { ConfigureNetworkForm } from "./ConfigureNetworkForm"; -function useChainConfigTrack() { - const trackEvent = useTrack(); - return (action: "add" | "update", chain: StoredChain) => { - trackEvent({ - category: "chain_configuration", - chain, - action, - }); - }; -} - interface ConfigureNetworksProps { onNetworkConfigured?: (chain: StoredChain) => void; onNetworkAdded?: (chain: StoredChain) => void; prefillSlug?: string; prefillChainId?: string; editChain: StoredChain | undefined; + client: ThirdwebClient; } export const ConfigureNetworks: React.FC = (props) => { - const trackChainConfig = useChainConfigTrack(); const { editChain } = props; const handleSubmit = (chain: StoredChain) => { @@ -36,17 +26,21 @@ export const ConfigureNetworks: React.FC = (props) => { if (chain.isCustom) { if (props.onNetworkAdded) { + reportChainConfigurationAdded({ + chainId: chain.chainId, + chainName: chain.name, + nativeCurrency: chain.nativeCurrency, + rpcURLs: chain.rpc, + }); props.onNetworkAdded(chain); } - trackChainConfig("add", chain); toast.success("Network Added Successfully"); } else { if (props.onNetworkConfigured) { props.onNetworkConfigured(chain); } - trackChainConfig("update", chain); toast.success("Network Updated Successfully"); } }; @@ -63,6 +57,7 @@ export const ConfigureNetworks: React.FC = (props) => { {/* Modify the given chain */} {editChain && ( @@ -71,9 +66,10 @@ export const ConfigureNetworks: React.FC = (props) => { {/* Custom chain */} {!editChain && ( )}
diff --git a/apps/dashboard/src/components/configure-networks/Form/ChainIdInput.tsx b/apps/dashboard/src/@/components/misc/configure-networks/Form/ChainIdInput.tsx similarity index 91% rename from apps/dashboard/src/components/configure-networks/Form/ChainIdInput.tsx rename to apps/dashboard/src/@/components/misc/configure-networks/Form/ChainIdInput.tsx index a99754fba8b..6456d96a42c 100644 --- a/apps/dashboard/src/components/configure-networks/Form/ChainIdInput.tsx +++ b/apps/dashboard/src/@/components/misc/configure-networks/Form/ChainIdInput.tsx @@ -1,28 +1,30 @@ +import { useId } from "react"; +import type { UseFormReturn } from "react-hook-form"; import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; import { Input } from "@/components/ui/input"; -import type { UseFormReturn } from "react-hook-form"; import type { NetworkConfigFormData } from "../ConfigureNetworkForm"; export const ChainIdInput: React.FC<{ form: UseFormReturn; disabled: boolean; }> = ({ form, disabled }) => { + const chainIdId = useId(); return ( { // prevent typing e, +, - if (e.key === "e" || e.key === "+" || e.key === "-") { diff --git a/apps/dashboard/src/@/components/misc/configure-networks/Form/IconUpload.tsx b/apps/dashboard/src/@/components/misc/configure-networks/Form/IconUpload.tsx new file mode 100644 index 00000000000..65f256949d9 --- /dev/null +++ b/apps/dashboard/src/@/components/misc/configure-networks/Form/IconUpload.tsx @@ -0,0 +1,65 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { PINNED_FILES_QUERY_KEY_ROOT } from "app/(app)/team/[team_slug]/(team)/~/usage/storage/your-files"; +import { UploadIcon } from "lucide-react"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { FileInput } from "@/components/blocks/FileInput"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/Spinner"; +import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload"; + +export const IconUpload: React.FC<{ + onUpload: (url: string) => void; + client: ThirdwebClient; +}> = ({ onUpload, client }) => { + const storageUpload = useDashboardStorageUpload({ client }); + const queryClient = useQueryClient(); + + const handleIconUpload = (file: File) => { + // if file size is larger than 5000kB, show error + if (file.size > 5000 * 1024) { + toast.error("Icon image can not be larger than 5MB"); + return; + } + + storageUpload.mutate([file], { + onError(error) { + console.error(error); + toast.error("Failed to upload icon"); + }, + onSuccess([uri]) { + if (uri) { + onUpload(uri); + } else { + toast.error("Something went wrong uploading icon"); + } + // also refetch the files list + queryClient.invalidateQueries({ + queryKey: [PINNED_FILES_QUERY_KEY_ROOT], + }); + }, + }); + }; + + return ( + + + + ); +}; diff --git a/apps/dashboard/src/components/configure-networks/Form/NetworkIdInput.tsx b/apps/dashboard/src/@/components/misc/configure-networks/Form/NetworkIdInput.tsx similarity index 85% rename from apps/dashboard/src/components/configure-networks/Form/NetworkIdInput.tsx rename to apps/dashboard/src/@/components/misc/configure-networks/Form/NetworkIdInput.tsx index 403945d8e48..a7c4d900dc9 100644 --- a/apps/dashboard/src/components/configure-networks/Form/NetworkIdInput.tsx +++ b/apps/dashboard/src/@/components/misc/configure-networks/Form/NetworkIdInput.tsx @@ -1,7 +1,8 @@ +import { useId } from "react"; +import type { UseFormReturn } from "react-hook-form"; import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; import { Input } from "@/components/ui/input"; -import type { UseFormReturn } from "react-hook-form"; -import { useAllChainsData } from "../../../hooks/chains/allChains"; +import { useAllChainsData } from "@/hooks/chains/allChains"; import type { NetworkConfigFormData } from "../ConfigureNetworkForm"; export const NetworkIDInput: React.FC<{ @@ -9,10 +10,15 @@ export const NetworkIDInput: React.FC<{ disabled?: boolean; }> = ({ form, disabled }) => { const { slugToChain } = useAllChainsData(); - + const slugId = useId(); return ( } - errorMessage={ - form.formState.errors.slug?.type === "taken" ? ( - <>Slug is taken by other network - ) : undefined - } > { // only allow alphanumeric characters and dashes if (!/^[a-z0-9-]*$/i.test(e.key)) { e.preventDefault(); } }} + placeholder="ethereum" type="text" {...form.register("slug", { required: true, diff --git a/apps/dashboard/src/components/configure-networks/Form/RpcInput.tsx b/apps/dashboard/src/@/components/misc/configure-networks/Form/RpcInput.tsx similarity index 91% rename from apps/dashboard/src/components/configure-networks/Form/RpcInput.tsx rename to apps/dashboard/src/@/components/misc/configure-networks/Form/RpcInput.tsx index 7b2128fe642..d29d4fe9f5f 100644 --- a/apps/dashboard/src/components/configure-networks/Form/RpcInput.tsx +++ b/apps/dashboard/src/@/components/misc/configure-networks/Form/RpcInput.tsx @@ -1,11 +1,13 @@ +import { useId } from "react"; +import type { UseFormReturn } from "react-hook-form"; import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; import { Input } from "@/components/ui/input"; -import type { UseFormReturn } from "react-hook-form"; import type { NetworkConfigFormData } from "../ConfigureNetworkForm"; export const RpcInput: React.FC<{ form: UseFormReturn; }> = ({ form }) => { + const rpcUrlId = useId(); const reg = form.register("rpcUrl", { required: true, validate: { @@ -22,17 +24,17 @@ export const RpcInput: React.FC<{ return ( diff --git a/apps/dashboard/src/components/configure-networks/LazyConfigureNetworkModal.tsx b/apps/dashboard/src/@/components/misc/configure-networks/LazyConfigureNetworkModal.tsx similarity index 100% rename from apps/dashboard/src/components/configure-networks/LazyConfigureNetworkModal.tsx rename to apps/dashboard/src/@/components/misc/configure-networks/LazyConfigureNetworkModal.tsx diff --git a/apps/dashboard/src/@/components/notifications/notification-button.tsx b/apps/dashboard/src/@/components/notifications/notification-button.tsx new file mode 100644 index 00000000000..f735bb24cbe --- /dev/null +++ b/apps/dashboard/src/@/components/notifications/notification-button.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { BellIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { NotificationList } from "./notification-list"; +import { useNotifications } from "./state/manager"; + +export function NotificationsButton(props: { accountId: string }) { + const manager = useNotifications(props.accountId); + const [open, setOpen] = useState(false); + + const isMobile = useIsMobile(); + + const trigger = useMemo( + () => ( + + ), + [manager.unreadNotificationsCount], + ); + + if (isMobile) { + return ( + + {trigger} + + Notifications + + + + ); + } + + return ( + + {trigger} + + + + + ); +} diff --git a/apps/dashboard/src/@/components/notifications/notification-entry.tsx b/apps/dashboard/src/@/components/notifications/notification-entry.tsx new file mode 100644 index 00000000000..ee943db4c0c --- /dev/null +++ b/apps/dashboard/src/@/components/notifications/notification-entry.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { + format, + formatDistanceToNow, + isBefore, + parseISO, + subDays, +} from "date-fns"; +import { ArchiveIcon, ArrowUpRightIcon, BellIcon } from "lucide-react"; +import { useMemo } from "react"; +import type { Notification } from "@/api/notifications"; +import { Button } from "@/components/ui/button"; + +interface NotificationEntryProps { + notification: Notification; + onMarkAsRead?: (id: string) => void; +} + +export function NotificationEntry({ + notification, + onMarkAsRead, +}: NotificationEntryProps) { + const timeAgo = useMemo(() => { + try { + const now = new Date(); + const date = parseISO(notification.createdAt); + // if the date is older than 1 day, show the date + // otherwise, show the time ago + + if (isBefore(date, subDays(now, 1))) { + return format(date, "MMM d, yyyy"); + } + + return formatDistanceToNow(date, { + addSuffix: true, + }); + } catch (error) { + console.error("Failed to parse date", error); + return null; + } + }, [notification.createdAt]); + + return ( +
+
+ +
+
+
+
+

{notification.description}

+ {timeAgo && ( +

{timeAgo}

+ )} + +
+
+ {onMarkAsRead && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/@/components/notifications/notification-list.tsx b/apps/dashboard/src/@/components/notifications/notification-list.tsx new file mode 100644 index 00000000000..c5fe986fe43 --- /dev/null +++ b/apps/dashboard/src/@/components/notifications/notification-list.tsx @@ -0,0 +1,202 @@ +"use client"; +import { ArchiveIcon, BellIcon, Loader2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { TabButtons } from "@/components/ui/tabs"; +import { NotificationEntry } from "./notification-entry"; +import type { useNotifications } from "./state/manager"; + +export function NotificationList(props: ReturnType) { + // default to inbox if there are unread notifications, otherwise default to archive + const [activeTab, setActiveTab] = useState( + props.unreadNotificationsCount > 0 + ? "inbox" + : // if we have archived notifications, default to archive + props.archivedNotifications.length > 0 + ? "archive" + : // otherwise defualt to inbox (if there are no archived notifications either) + "inbox", + ); + + const scrollContainerRef = useRef(null); + + return ( +
+ + Inbox + {props.unreadNotificationsCount > 0 && ( + + {props.unreadNotificationsCount} + + )} +
+ ), + onClick: () => setActiveTab("inbox"), + }, + { + isActive: activeTab === "archive", + name: "Archive", + onClick: () => setActiveTab("archive"), + }, + ]} + /> + +
+ {activeTab === "inbox" ? ( + + ) : ( + + )} +
+
+ ); +} + +function InboxTab( + props: Pick< + ReturnType, + | "unreadNotifications" + | "isLoadingUnread" + | "hasMoreUnread" + | "isFetchingMoreUnread" + | "loadMoreUnread" + | "markAsRead" + | "markAllAsRead" + > & { + scrollContainerRef: React.RefObject; + }, +) { + return props.unreadNotifications.length === 0 ? ( +
+
+ +
+

No new notifications

+
+ ) : ( + <> + {props.unreadNotifications.map((notification) => ( + + ))} + + + ); +} + +function ArchiveTab( + props: Pick< + ReturnType, + | "archivedNotifications" + | "isLoadingArchived" + | "hasMoreArchived" + | "isFetchingMoreArchived" + | "loadMoreArchived" + > & { + scrollContainerRef: React.RefObject; + }, +) { + return props.archivedNotifications.length === 0 ? ( +
+
+ +
+

No archived notifications

+
+ ) : ( + <> + {props.archivedNotifications.map((notification) => ( + + ))} + + + ); +} + +function AutoLoadMore(props: { + hasMore: boolean; + isLoading: boolean; + loadMore: () => void; + scrollContainerRef: React.RefObject; +}) { + const ref = useRef(null); + + // if the element is scrolled into view, load more + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // only run if the ref and scroll container ref are defined + if (ref.current && props.scrollContainerRef.current) { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !props.isLoading && props.hasMore) { + props.loadMore(); + observer.unobserve(entry.target); // prevent duplicate fires + } + } + }, + { root: props.scrollContainerRef.current, threshold: 0.1 }, + ); + + observer.observe(ref.current); + + return () => observer.disconnect(); + } + }, [ + props.hasMore, + props.isLoading, + props.loadMore, + props.scrollContainerRef, + ]); + + if (!props.hasMore) return null; + + if (props.isLoading) { + return ( +
+ +
+ ); + } + + return
; +} diff --git a/apps/dashboard/src/@/components/notifications/state/manager.ts b/apps/dashboard/src/@/components/notifications/state/manager.ts new file mode 100644 index 00000000000..390770b8636 --- /dev/null +++ b/apps/dashboard/src/@/components/notifications/state/manager.ts @@ -0,0 +1,213 @@ +"use client"; + +import { + type InfiniteData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useMemo } from "react"; +import { toast } from "sonner"; +import { + getArchivedNotifications, + getUnreadNotifications, + getUnreadNotificationsCount, + markNotificationAsRead, + type Notification, + type NotificationsApiResponse, +} from "@/api/notifications"; + +/** + * Internal helper to safely flatten pages coming from useInfiniteQuery. + */ +function flattenPages( + data?: InfiniteData, +): Notification[] { + return data?.pages.flatMap((p) => p.result) ?? []; +} + +/** + * React hook that provides notifications state with optimistic archiving. + * + * Example: + * ```tsx + * const { + * unreadNotifications, + * archivedNotifications, + * unreadCount, + * markAsRead, + * markAllAsRead, + * loadMoreUnread, + * loadMoreArchived, + * hasMoreUnread, + * hasMoreArchived, + * } = useNotifications(); + * ``` + */ + +export function useNotifications(accountId: string) { + const queryClient = useQueryClient(); + + // -------------------- + // Query definitions + // -------------------- + + const unreadQueryKey = useMemo( + () => ["notifications", "unread", { accountId }], + [accountId], + ); + const archivedQueryKey = useMemo( + () => ["notifications", "archived", { accountId }], + [accountId], + ); + const unreadCountKey = useMemo( + () => ["notifications", "unread-count", { accountId }], + [accountId], + ); + + const unreadQuery = useInfiniteQuery({ + enabled: !!accountId, + getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => { + const cursor = (pageParam ?? undefined) as string | undefined; + const res = await getUnreadNotifications(cursor); + if (res.status === "error") { + throw new Error(res.reason ?? "unknown"); + } + return res.data; + }, + queryKey: unreadQueryKey, + refetchInterval: 60_000, // 1min + }); + + const archivedQuery = useInfiniteQuery({ + enabled: !!accountId, + getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => { + const cursor = (pageParam ?? undefined) as string | undefined; + const res = await getArchivedNotifications(cursor); + if (res.status === "error") { + throw new Error(res.reason ?? "unknown"); + } + return res.data; + }, + queryKey: archivedQueryKey, + refetchInterval: 60_000, // 1min + }); + + const unreadCountQuery = useQuery({ + enabled: !!accountId, + queryFn: async () => { + const res = await getUnreadNotificationsCount(); + if (res.status === "error") { + throw new Error(res.reason ?? "unknown"); + } + return res.data.result.unreadCount; + }, + queryKey: unreadCountKey, // 1min + refetchInterval: 60_000, + }); + + // -------------------- + // Mutation (archive) + // -------------------- + const archiveMutation = useMutation({ + mutationFn: async (notificationId?: string) => { + const res = await markNotificationAsRead(notificationId); + if (res.status === "error") { + toast.error("Failed to mark notification as read"); + throw new Error(res.reason ?? "unknown"); + } + return { notificationId } as const; + }, + // Optimistic update + onMutate: async (notificationId) => { + await Promise.all([ + queryClient.cancelQueries({ queryKey: unreadQueryKey }), + queryClient.cancelQueries({ queryKey: archivedQueryKey }), + ]); + + const previousUnread = + queryClient.getQueryData>( + unreadQueryKey, + ); + + const previousCount = queryClient.getQueryData(unreadCountKey); + + let optimisticUnread: InfiniteData | undefined; + let optimisticCount: number | undefined; + + if (previousUnread) { + if (notificationId) { + // Remove a single notification + optimisticUnread = { + ...previousUnread, + pages: previousUnread.pages.map((page) => ({ + ...page, + result: page.result.filter((n) => n.id !== notificationId), + })), + }; + if (typeof previousCount === "number") { + optimisticCount = Math.max(previousCount - 1, 0); + } + } else { + // Clear all unread notifications + optimisticUnread = { + ...previousUnread, + pages: previousUnread.pages.map((page) => ({ + ...page, + result: [], + })), + }; + optimisticCount = 0; + } + queryClient.setQueryData(unreadQueryKey, optimisticUnread); + } + + if (typeof optimisticCount === "number") { + queryClient.setQueryData(unreadCountKey, optimisticCount); + } + + return { previousCount, previousUnread } as const; + }, + // Always refetch to ensure consistency + onSettled: () => { + queryClient.invalidateQueries({ queryKey: unreadQueryKey }); + queryClient.invalidateQueries({ queryKey: archivedQueryKey }); + queryClient.invalidateQueries({ queryKey: unreadCountKey }); + }, + }); + + // -------------------- + // Derived helpers + // -------------------- + const unreadNotifications = flattenPages(unreadQuery.data); + const archivedNotifications = flattenPages(archivedQuery.data); + const unreadNotificationsCount = unreadCountQuery.data ?? 0; // this is the total unread count + + return { + archivedNotifications, + hasMoreArchived: archivedQuery.hasNextPage ?? false, + hasMoreUnread: unreadQuery.hasNextPage ?? false, + isFetchingMoreArchived: archivedQuery.isFetchingNextPage, + isFetchingMoreUnread: unreadQuery.isFetchingNextPage, + isLoadingArchived: archivedQuery.isLoading, + + // booleans + isLoadingUnread: unreadQuery.isLoading, + loadMoreArchived: () => archivedQuery.fetchNextPage(), + + // pagination helpers + loadMoreUnread: () => unreadQuery.fetchNextPage(), + markAllAsRead: () => archiveMutation.mutate(undefined), + + // mutations + markAsRead: (id: string) => archiveMutation.mutate(id), + // data + unreadNotifications, + unreadNotificationsCount, + } as const; +} diff --git a/apps/dashboard/src/@/components/pagination-buttons.stories.tsx b/apps/dashboard/src/@/components/pagination-buttons.stories.tsx deleted file mode 100644 index 9c6d522472b..00000000000 --- a/apps/dashboard/src/@/components/pagination-buttons.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; -import { BadgeContainer, mobileViewport } from "../../stories/utils"; -import { PaginationButtons } from "./pagination-buttons"; - -const meta = { - title: "blocks/PaginationButtons", - component: Story, - parameters: { - nextjs: { - appDirectory: true, - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Desktop: Story = { - args: {}, -}; - -export const Mobile: Story = { - args: {}, - parameters: { - viewport: mobileViewport("iphone14"), - }, -}; - -function Story() { - return ( -
- - - - - -
- ); -} - -function Variant(props: { - label: string; - totalPages: number; -}) { - const [activePage, setActivePage] = useState(1); - return ( - -
- -
-
- ); -} diff --git a/apps/dashboard/src/@/components/pagination-buttons.tsx b/apps/dashboard/src/@/components/pagination-buttons.tsx deleted file mode 100644 index 0074d246fe2..00000000000 --- a/apps/dashboard/src/@/components/pagination-buttons.tsx +++ /dev/null @@ -1,183 +0,0 @@ -"use client"; - -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; -import { ArrowUpRightIcon } from "lucide-react"; -import { useState } from "react"; -import { cn } from "../lib/utils"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; - -export const PaginationButtons = (props: { - activePage: number; - totalPages: number; - onPageClick: (page: number) => void; -}) => { - const { activePage, totalPages, onPageClick: setPage } = props; - const [inputHasError, setInputHasError] = useState(false); - const [pageNumberInput, setPageNumberInput] = useState(""); - - if (totalPages === 1) { - return null; - } - - function handlePageInputSubmit() { - const page = Number(pageNumberInput); - - setInputHasError(false); - if (Number.isInteger(page) && page > 0 && page <= totalPages) { - setPage(page); - setPageNumberInput(""); - } else { - setInputHasError(true); - } - } - - // just render all the page buttons directly - if (totalPages <= 6) { - const pages = [...Array(totalPages)].map((_, i) => i + 1); - return ( - - - {pages.map((page) => ( - - { - setPage(page); - }} - > - {page} - - - ))} - - - ); - } - - return ( - - - - { - setPage(activePage - 1); - }} - /> - - - {/* First page + ... */} - {activePage - 3 > 0 && ( - <> - - { - setPage(1); - }} - > - 1 - - - - - - - - )} - - {activePage - 1 > 0 && ( - - { - setPage(activePage - 1); - }} - > - {activePage - 1} - - - )} - - - {activePage} - - - {activePage + 1 <= totalPages && ( - - { - setPage(activePage + 1); - }} - > - {activePage + 1} - - - )} - - {/* ... + Last page */} - {activePage + 3 <= totalPages && ( - <> - - - - - - { - setPage(totalPages); - }} - > - {totalPages} - - - - )} - - - { - setPage(activePage + 1); - }} - /> - - -
- { - setInputHasError(false); - setPageNumberInput(e.target.value); - }} - type="number" - placeholder="Page" - className={cn( - "w-[60px] bg-transparent [appearance:textfield] max-sm:placeholder:text-sm lg:w-[100px] lg:pr-8 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", - inputHasError && "text-red-500", - )} - onKeyDown={(e) => { - if (e.key === "Enter") { - handlePageInputSubmit(); - } - }} - /> - -
-
-
- ); -}; diff --git a/apps/dashboard/src/@/components/project/create-project-modal/CreateApiKeyModal.stories.tsx b/apps/dashboard/src/@/components/project/create-project-modal/CreateApiKeyModal.stories.tsx new file mode 100644 index 00000000000..e4e05ebf6c1 --- /dev/null +++ b/apps/dashboard/src/@/components/project/create-project-modal/CreateApiKeyModal.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { projectStub } from "@/storybook/stubs"; +import { CreateProjectDialogUI, type CreateProjectPrefillOptions } from "."; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "Project/Create Project Modal", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story(props: { prefill?: CreateProjectPrefillOptions }) { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + project: projectStub("foo", "bar"), + secret: "123", + }; + }} + enableNebulaServiceByDefault={false} + onOpenChange={setIsOpen} + open={isOpen} + prefill={props.prefill} + teamSlug="foo" + /> + + +
+ ); +} diff --git a/apps/dashboard/src/@/components/project/create-project-modal/LazyCreateAPIKeyDialog.tsx b/apps/dashboard/src/@/components/project/create-project-modal/LazyCreateAPIKeyDialog.tsx new file mode 100644 index 00000000000..724912cacf4 --- /dev/null +++ b/apps/dashboard/src/@/components/project/create-project-modal/LazyCreateAPIKeyDialog.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { lazy, Suspense, useEffect, useState } from "react"; +import type { CreateProjectDialogProps } from "./index"; + +const CreateProjectDialog = lazy(() => import("./index")); + +export function LazyCreateProjectDialog(props: CreateProjectDialogProps) { + // if we use props.open to conditionally render the lazy component, - the dialog will close suddenly when the user closes it instead of gracefully fading out + // and we can't render the dialog unconditionally because it will be rendered on the first page load and that defeats the purpose of lazy loading + const [hasEverOpened, setHasEverOpened] = useState(false); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (props.open) { + setHasEverOpened(true); + } + }, [props.open]); + + if (hasEverOpened) { + return ( + + + + ); + } + + return null; +} diff --git a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx new file mode 100644 index 00000000000..1dec37b0d5d --- /dev/null +++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx @@ -0,0 +1,529 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import type { ProjectService } from "@thirdweb-dev/service-utils"; +import { SERVICES } from "@thirdweb-dev/service-utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import type { Project } from "@/api/project/projects"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/Spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { createProjectClient } from "@/hooks/useApi"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; +import { toArrFromList } from "@/utils/string"; +import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; + +const ALL_PROJECT_SERVICES = SERVICES.filter( + (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", +); + +export type CreateProjectPrefillOptions = { + name?: string; + domains?: string; +}; + +export type CreateProjectDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreateAndComplete?: () => void; + prefill?: CreateProjectPrefillOptions; + enableNebulaServiceByDefault: boolean; + teamId: string; + teamSlug: string; +}; + +const CreateProjectDialog = (props: CreateProjectDialogProps) => { + return ( + { + const res = await createProjectClient(props.teamId, params); + const vaultTokens = await createVaultAccountAndAccessToken({ + project: res.project, + projectSecretKey: res.secret, + }).catch((error) => { + console.error( + "Failed to create vault account and access token", + error, + ); + throw error; + }); + + const managementAccessToken = vaultTokens.managementToken?.accessToken; + + if (!managementAccessToken) { + throw new Error("Missing management access token for project wallet"); + } + + return { + project: res.project, + secret: res.secret, + }; + }} + {...props} + /> + ); +}; + +export default CreateProjectDialog; + +export const CreateProjectDialogUI = (props: { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreateAndComplete?: () => void; + createProject: (param: Partial) => Promise<{ + project: Project; + secret: string; + }>; + prefill?: CreateProjectPrefillOptions; + enableNebulaServiceByDefault: boolean; + teamSlug: string; +}) => { + const [screen, setScreen] = useState< + { id: "create" } | { id: "api-details"; project: Project; secret: string } + >({ id: "create" }); + const { open, onOpenChange } = props; + + return ( + { + // Prevent closing the dialog when the API key is created - to make sure user does not accidentally close the dialog without copying the secret key + if (screen.id === "api-details") { + return; + } + + onOpenChange(v); + }} + open={open} + > + + + {screen.id === "create" && ( + { + setScreen({ + id: "api-details", + project: params.project, + secret: params.secret, + }); + }} + prefill={props.prefill} + /> + )} + + {screen.id === "api-details" && ( + { + onOpenChange(false); + setScreen({ id: "create" }); + props.onCreateAndComplete?.(); + }} + project={screen.project} + secret={screen.secret} + teamSlug={props.teamSlug} + /> + )} + + + + ); +}; + +const createProjectFormSchema = z.object({ + domains: projectDomainsSchema, + name: projectNameSchema, +}); + +type CreateProjectFormSchema = z.infer; + +function CreateProjectForm(props: { + createProject: (param: Partial) => Promise<{ + project: Project; + secret: string; + }>; + prefill?: CreateProjectPrefillOptions; + enableNebulaServiceByDefault: boolean; + onProjectCreated: (params: { project: Project; secret: string }) => void; +}) { + const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">(); + + const createProject = useMutation({ + mutationFn: props.createProject, + }); + + const form = useForm({ + defaultValues: { + domains: props.prefill?.domains || "", + name: props.prefill?.name || "", + }, + resolver: zodResolver(createProjectFormSchema), + }); + + function handleAPICreation(values: { name: string; domains: string }) { + const servicesToEnableByDefault = props.enableNebulaServiceByDefault + ? ALL_PROJECT_SERVICES + : ALL_PROJECT_SERVICES.filter((srv) => srv.name !== "nebula"); + + const formattedValues: Partial = { + domains: toArrFromList(values.domains), + name: values.name, + // enable all services + services: servicesToEnableByDefault.map((srv) => { + if (srv.name === "storage") { + return { + actions: srv.actions.map((sa) => sa.name), + name: srv.name, + } satisfies ProjectService; + } + + if (srv.name === "pay") { + return { + actions: [], + name: "pay", + payoutAddress: null, + } satisfies ProjectService; + } + + return { + actions: [], + name: srv.name, + } satisfies ProjectService; + }), + }; + + createProject.mutate(formattedValues, { + onError: () => { + toast.error("Failed to create a project"); + }, + onSuccess: (data) => { + props.onProjectCreated(data); + toast.success("Project created successfully"); + }, + }); + } + + const handleSubmit = form.handleSubmit((values) => { + if (!values.domains) { + setShowAlert("no-domain"); + } else if (values.domains === "*") { + setShowAlert("any-domain"); + } else { + handleAPICreation({ + domains: values.domains, + name: values.name, + }); + } + }); + + if (showAlert) { + return ( + setShowAlert(undefined)} + onProceed={() => { + handleAPICreation({ + domains: form.getValues("domains"), + name: form.getValues("name"), + }); + }} + type={showAlert} + /> + ); + } + + return ( +
+ +
+ + Create Project + + +
+ ( + + Project Name + + + + + + )} + /> + +
+ + { + form.setValue("domains", checked ? "*" : "", { + shouldDirty: true, + }); + }} + /> + Allow all domains + + + ( + + Allowed Domains + +