Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3c768a0ef9 Version Packages (#8503)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-05 23:07:48 -08:00
FirekeeperandGitHub a388439022 Add Solana facilitator guide to docs (#8511) 2025-12-06 05:53:27 +07:00
MananTank 7b873d38db [MNY-322] SDK: Update success message shown in SwapWidget component (#8509)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the success messages in the `SuccessScreen` component across various widgets in the `thirdweb` package, specifically for payment and swap transactions.

### Detailed summary
- Updated success message type in `SwapWidget` to `"swap-success"`.
- Updated success message type in `BuyWidget` and `CheckoutWidget` to `"payment-success"`.
- Added a new story `BasicSwapSuccess` in `SuccessScreen.stories.tsx`.
- Modified `SuccessScreen` to handle different message types.
- Updated displayed titles and descriptions based on the success type.
- Changed button text from "View Payment Receipt" to "View Transaction Receipt".

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Success screens now display dynamic, context-appropriate messaging tailored to transaction type, with distinct titles and descriptions for swap versus payment transactions.
  * Enhanced user experience with improved terminology—the option to "View Transaction Receipt" now displays consistently across all payment flows for better clarity and consistency.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-05 20:46:01 +00:00
Jonas Daniels d33a943176 Add AI privacy policy and terms to framer-rewrites.js 2025-12-05 12:38:02 -08:00
FirekeeperandGitHub d5e0b6eb73 Update API reference links in dashboard and portal (#8508) 2025-12-06 02:57:42 +07:00
Joaquim VergesandGitHub 75e9be0358 [SDK] Update token balances query to use client instead of clientId (#8507) 2025-12-04 18:32:44 -08:00
joaquim-verges a704c3f2b0 [SDK] Allow passing activeWallet to SwapWidget (#8504)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `SwapWidget` by allowing the passing of an `activeWallet` prop and improving the project wallet functionality in the dashboard. It also includes updates to the VSCode settings and introduces new components for swapping tokens.

### Detailed summary
- Added `activeWallet` prop to `SwapWidgetProps`.
- Modified `useActiveWalletInfo` to accept an `activeWalletOverride`.
- Introduced `SwapProjectWalletModalContent` for token swapping.
- Updated `ProjectWalletDetailsSection` to include swap functionality.
- Adjusted VSCode settings for formatting preferences.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added in-app token swap functionality for project wallets, allowing users to exchange tokens directly from the wallet interface with credentials management.
  * SwapWidget now supports wallet pre-selection, enabling faster and more streamlined token swap initiation.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-05 00:03:49 +00:00
MananTank b0eccf365a [MNY-318] SDK: Remove tabs from token selection ui in SwapWidget (#8501)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `SelectToken` component by simplifying token limit management, improving UI consistency, and refactoring token display logic. It also removes unnecessary tabs and optimizes the selection process for better user experience.

### Detailed summary
- Replaced constants `ALL_TOKENS_LIMIT` and `OWNED_TOKENS_LIMIT` with `INITIAL_LIMIT`.
- Unified token limit state management into a single `limit` state.
- Refactored token fetching and display logic to streamline data handling.
- Removed unnecessary tab UI for better clarity.
- Simplified the structure of token data handling in `SelectTokenUI`.
- Updated the rendering logic for owned and other tokens to improve performance and readability.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **UI Improvements**
  * Redesigned token selection UI in SwapWidget: removed tab navigation and consolidated token lists into dedicated "Your Tokens" and "Other Tokens" sections for a clearer, more intuitive experience.

* **Bug Fixes**
  * Enhanced search functionality to properly reset token filtering, improving responsiveness.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 22:09:50 +00:00
MananTank f3525c7c01 [PRO-123] Dashboard: Move project webhooks (#8500)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring and reorganizing the `webhooks` feature within the application, including changes to routing and component structure, as well as the introduction of a `bridge` section for webhooks.

### Detailed summary
- Deleted multiple files related to `webhooks` under the previous structure.
- Updated routing paths from `/webhooks/payments` to `/bridge/webhooks`.
- Refactored components to align with the new `bridge` structure.
- Adjusted imports to reflect new file locations.
- Removed unused `WebhookIcon` and streamlined sidebar links.
- Introduced new layout and page components under `tokens/(general)/webhooks`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **Navigation & Organization**
  - Webhooks functionality relocated to the Bridge section for improved contextual organization
  - Updated navigation paths and sidebar structure accordingly

* **UI Improvements**
  - Redesigned empty state for webhooks with enhanced visual styling and icon display

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 21:35:46 +00:00
MananTank e516b4ddc1 [PRO-147] Dashboard: Update empty state for project analytics charts (#8499)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of empty chart states across various components in the dashboard by standardizing the `EmptyChartState` usage and enhancing the user experience with informative content.

### Detailed summary
- Updated `EmptyChartState` components to use `content` prop instead of `type`.
- Enhanced `EmptyChartState` with more descriptive text and links.
- Introduced `EmptyChartStateGetStartedCTA` for better guidance in empty states.
- Adjusted multiple charts to incorporate the new empty state handling.
- Simplified chart rendering logic when no data is available.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **UI/UX Improvements**
  * Unified and simplified empty-chart visuals (smaller message, cleaner layout, adjusted loading background) and consistent spacing across analytics cards.
  * Empty states now show contextual CTAs linking to relevant configuration/details pages.

* **New Features**
  * Reusable "Get Started" CTA for empty charts with title, description and action.

* **Refactor**
  * Charts can now accept and render provided empty-state content for a more consistent UX.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 18:38:25 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
dffa457a39 Version Packages (#8498)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 19:50:37 -08:00
Joaquim VergesandGitHub 859032dc17 [SDK] Skip swap review step when coming from swap widget (#8496) 2025-12-03 19:23:42 -08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e0ee69b061 Version Packages (#8494)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 17:47:50 -08:00
Joaquim VergesandGitHub b6be8d67ef [SDK] Faster token balance load query (#8495) 2025-12-03 17:38:16 -08:00
Joaquim VergesandGitHub 6ddb15cf02 [SDK] Optimize payment methods query by removing quotes and improving balance checks (#8493) 2025-12-03 17:00:25 -08:00
joaquim-verges 551ec680df [SDK] Add payment selection tracking (#8490)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing payment selection tracking throughout the `thirdweb` package by replacing `useQuery` calls with `useEffect` hooks to ensure that payment events are tracked only once per component lifecycle.

### Detailed summary
- Replaced `useQuery` for tracking payment events with `useEffect` in multiple components.
- Added `useRef` to prevent multiple event firings.
- Updated tracking events in:
  - `UnsupportedTokenScreen`
  - `BridgeWidget`
  - `ErrorBanner`
  - `QuoteLoader`
  - `PaymentSelection`
  - `PaymentDetails`
  - `SwapWidget`
  - `SuccessScreen`
  - `CheckoutWidget`
  - `TransactionWidget`
  - `BuyWidget`
  - `BridgeWidget`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Chores**
  * Refined Bridge telemetry: replaced reactive query-based tracking with guarded one-time render and interaction events to reduce duplicates.
  * Expanded analytics coverage for widgets, payment selection, loading/quote stages, payment details, swaps, unsupported-token and success flows.
  * Cleaned up and simplified tracking behavior: some legacy query-driven trackers were removed or consolidated for clearer, more reliable events.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 00:32:15 +00:00
Jonas Daniels 7b9ce493c8 upgrade next and react 2025-12-03 14:38:01 -08:00
0xFirekeeper b27fcd3ba0 Add Solana wallet docs and sidebar link (#8489)
Added a new sidebar entry for Solana Transactions in the wallets section and introduced a comprehensive Solana server wallet documentation page covering wallet management, balances, transactions, signing, swaps, and example API workflows.

Closes BLD-414

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces support for managing Solana transactions in the application, including wallet creation, balance checking, token transfers, and swaps. It enhances the user interface with detailed documentation and examples for developers.

### Detailed summary
- Added a new sidebar entry for `Solana Transactions`.
- Created a detailed `page.mdx` for Solana server wallets.
- Included metadata for the `Solana Server Wallets` page.
- Documented capabilities: wallet management, balance checking, signing, token swaps, and custom transactions.
- Provided example workflows using `curl` commands for various operations.
- Defined network identifiers for `Mainnet` and `Devnet`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added Solana Transactions link in Server Wallets navigation for streamlined access to Solana wallet operations

* **Documentation**
  * New Solana server wallets documentation page with getting started guides, API workflows for wallet creation, balance retrieval, token transfers, transaction signing, and token swap operations

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-03 19:08:53 +00:00
MananTank c40603f36e [PRO-134] Add Bridge Volume pie chart in project overview page (#8488)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the analytics components by refining data formatting, adding currency support, and improving the UI for better data visualization in the dashboard.

### Detailed summary
- Added `maximumFractionDigits` to formatting utilities.
- Integrated currency formatting in `PieChart` and `TotalValueChartHeader`.
- Updated `ProjectHighlightsCard` to remove total volume and adjust labels.
- Introduced `BridgeChartCard` for displaying bridge usage data.
- Enhanced `TotalVolumePieChart` to conditionally display total volume and adjusted data handling.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-12-03 18:46:13 +00:00
MananTank 458246fea1 Dashboard: Project analytics code organization refactor (#8487)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces several analytics components for a dashboard application, enhancing the project's data visualization capabilities. It includes new types for page parameters and search parameters, as well as multiple asynchronous chart components for different metrics.

### Detailed summary
- Added `PageParams` and `PageSearchParams` types for handling routing parameters.
- Implemented `AsyncRPCRequestsChartCard` and `RPCRequestsChartCard` for RPC usage analytics.
- Created `AsyncAiAnalytics` and `AIAnalyticsChartCard` for AI usage analytics.
- Introduced `AsyncIndexerRequestsChartCard` and `IndexerRequestsChartCard` for indexer request analytics.
- Developed `AsyncX402RequestsChart` and `X402RequestsChartCard` for x402 settlements analytics.
- Added `AsyncAppHighlightsCard` and `ProjectHighlightCard` for displaying project highlights.
- Refactored `ProjectAnalytics` function to integrate new chart components.
- Removed redundant code and improved structure for better maintainability.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added AI token volume analytics chart to project dashboard
  * Added indexer/gateway request analytics chart
  * Added RPC usage analytics chart
  * Added X402 settlements analytics chart
  * Introduced project highlights card displaying wallet and bridge usage metrics

* **Refactor**
  * Reorganized project analytics dashboard structure for improved layout and user experience

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-03 18:46:13 +00:00
101 changed files with 4692 additions and 3510 deletions
+5 -2
View File
@@ -1,4 +1,6 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 2,
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
@@ -18,8 +20,9 @@
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.fixAll.biome": "explicit"
"quickfix.biome": "always",
"source.fixAll.biome": "always",
"source.organizeImports.biome": "always"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
+3
View File
@@ -68,4 +68,7 @@ module.exports = [
"/token",
"/vault",
"/monetize/bridge",
// ai
"/ai-privacy-policy",
"/ai-terms",
];
+130 -130
View File
@@ -1,132 +1,132 @@
{
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@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:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^7.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.1.0",
"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.3",
"remark-gfm": "4.0.1",
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"spdx-correct": "^3.2.0",
"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.5",
"vaul": "^1.1.2",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.5",
"@next/eslint-plugin-next": "15.3.5",
"@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.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^5.18.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"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.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "thirdweb-dashboard",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"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"
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@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:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^7.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.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.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.3",
"remark-gfm": "4.0.1",
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"spdx-correct": "^3.2.0",
"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.5",
"vaul": "^1.1.2",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.6",
"@next/eslint-plugin-next": "15.3.6",
"@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.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@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": "^5.18.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"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.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "thirdweb-dashboard",
"private": true,
"scripts": {
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"build-storybook": "storybook build",
"build:analyze": "ANALYZE=true pnpm run build",
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"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"
}
@@ -1,50 +1,23 @@
"use client";
import { XIcon } from "lucide-react";
import { useId, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart } from "recharts";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
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";
type FakeCartData = {
value: number;
};
function generateRandomData(): FakeCartData[] {
return Array.from({ length: 30 }, () => ({
value: Math.floor(Math.random() * 100 + 30),
}));
}
const skeletonChartConfig = {
value: {
color: "hsl(var(--muted)/90%)",
label: "Value",
},
} satisfies ChartConfig;
export function EmptyChartState({
children,
type,
}: {
children?: React.ReactNode;
type: "bar" | "area";
}) {
const barChartData = useMemo(() => generateRandomData(), []);
export function EmptyChartState({ content }: { content?: React.ReactNode }) {
return (
<div className="relative z-0 h-full w-full">
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center text-base text-muted-foreground">
{children ?? (
{content || (
<div className="flex items-center gap-3 flex-col">
<div className="rounded-full border p-2 bg-background">
<XIcon className="size-4" />
</div>
<p className="text-base"> No data available </p>
<p className="text-sm"> No data available </p>
</div>
)}
</div>
<SkeletonBarChart data={barChartData} type={type} />
</div>
);
}
@@ -53,7 +26,7 @@ export function LoadingChartState({ className }: { className?: string }) {
return (
<div
className={cn(
"pointer-events-none flex h-full w-full items-center justify-center rounded-lg bg-muted/30",
"pointer-events-none flex h-full w-full items-center justify-center bg-card rounded-lg",
className,
)}
>
@@ -62,51 +35,40 @@ export function LoadingChartState({ className }: { className?: string }) {
);
}
function SkeletonBarChart(props: {
data: FakeCartData[];
type: "bar" | "area";
export function EmptyChartStateGetStartedCTA(props: {
link: {
label: string;
href: string;
};
title: string;
description?: string;
}) {
const fillAreaSkeletonId = useId();
return (
<ChartContainer
className="pointer-events-none h-full w-full blur-[5px] aspect-auto"
config={skeletonChartConfig}
>
{props.type === "bar" ? (
<BarChart
data={props.data}
margin={{
top: 20,
}}
>
<Bar dataKey="value" fill="var(--color-value)" radius={8} />
</BarChart>
) : (
<AreaChart
data={props.data}
margin={{
top: 20,
}}
>
<defs>
<linearGradient id={fillAreaSkeletonId} x1="0" x2="0" y1="0" y2="1">
<stop
offset="5%"
stopColor={"hsl(var(--muted-foreground)/0.5)"}
stopOpacity={0.8}
/>
<stop offset="95%" stopColor="transparent" stopOpacity={0.1} />
</linearGradient>
</defs>
<Area
dataKey="value"
fill={`url(#${fillAreaSkeletonId})`}
radius={8}
stroke="hsl(var(--muted-foreground))"
type="monotone"
/>
</AreaChart>
)}
</ChartContainer>
<div className="flex items-center flex-col">
<div className="rounded-full border p-2 mb-4">
<XIcon className="size-4 text-foreground" />
</div>
<div className="mb-5 space-y-1">
<h3 className="text-base text-foreground text-center font-medium">
{props.title}
</h3>
{props.description && (
<p className="text-center text-sm text-muted-foreground">
{props.description}
</p>
)}
</div>
<Button
asChild
className="rounded-full gap-2"
variant="default"
size="sm"
>
<Link href={props.link.href}>
{props.link.label} <ArrowUpRightIcon className="size-4" />
</Link>
</Button>
</div>
);
}
@@ -90,9 +90,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
<EmptyChartState type="area">
{props.emptyChartState}
</EmptyChartState>
<EmptyChartState content={props.emptyChartState} />
) : (
<AreaChart
accessibilityLayer
@@ -78,9 +78,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
<EmptyChartState type="bar">
{props.emptyChartState}
</EmptyChartState>
<EmptyChartState content={props.emptyChartState} />
) : (
<BarChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
@@ -6,6 +6,7 @@ import { SkeletonContainer } from "@/components/ui/skeleton";
export function TotalValueChartHeader(props: {
total: number;
title: string;
isUsd?: boolean;
isPending: boolean;
viewMoreLink: string | undefined;
}) {
@@ -18,7 +19,9 @@ export function TotalValueChartHeader(props: {
render={(value) => {
return (
<p className="text-3xl font-semibold tracking-tight">
{compactNumberFormatter.format(value)}
{props.isUsd
? compactUSDFormatter.format(value)
: compactNumberFormatter.format(value)}
</p>
);
}}
@@ -48,3 +51,10 @@ 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",
});
@@ -13,6 +13,7 @@ import {
import {
THIRDWEB_BRIDGE_URL,
THIRDWEB_BUNDLER_DOMAIN,
THIRDWEB_ENGINE_CLOUD_URL,
THIRDWEB_INAPP_WALLET_DOMAIN,
THIRDWEB_INSIGHT_API_DOMAIN,
THIRDWEB_PAY_DOMAIN,
@@ -38,6 +39,7 @@ export function getConfiguredThirdwebClient(options: {
rpc: THIRDWEB_RPC_DOMAIN,
social: THIRDWEB_SOCIAL_API_DOMAIN,
storage: THIRDWEB_STORAGE_DOMAIN,
engineCloud: new URL(THIRDWEB_ENGINE_CLOUD_URL).hostname,
});
}
+3
View File
@@ -25,3 +25,6 @@ export const THIRDWEB_INSIGHT_API_DOMAIN =
export const THIRDWEB_BRIDGE_URL =
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb-dev.com";
export const THIRDWEB_ENGINE_CLOUD_URL =
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "https://engine.thirdweb-dev.com";
@@ -1,5 +1,6 @@
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
export const formatTickerNumber = (value: number) => {
@@ -63,15 +63,17 @@ function MetricPlaceholders({
{metric.label}
</span>
<div className="h-32 w-full">
<EmptyChartState type="area">
{status === "active" ? (
<Badge>Coming Soon</Badge>
) : status === "pending" ? (
<p className="text-xs">Activation in progress.</p>
) : (
<p className="text-xs">Activate service to view metrics.</p>
)}
</EmptyChartState>
<EmptyChartState
content={
status === "active" ? (
<Badge>Coming Soon</Badge>
) : status === "pending" ? (
<p className="text-xs">Activation in progress.</p>
) : (
<p className="text-xs">Activate service to view metrics.</p>
)
}
/>
</div>
</Card>
))}
@@ -0,0 +1,84 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getAiUsage } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { AiTokenUsageChartCardUI } from "../ai/analytics/chart/AiTokenUsageChartCard";
async function AsyncAiAnalytics(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const stats = await getAiUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<AiTokenUsageChartCardUI
title="AI token volume"
isPending={false}
aiUsageStats={stats}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`,
}}
title="No data available"
description="No AI usage found in selected time period"
/>
}
/>
);
}
export function AIAnalyticsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={
<AiTokenUsageChartCardUI
isPending={true}
title="AI token volume"
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
aiUsageStats={[]}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAiAnalytics
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
from={props.from}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
@@ -1,5 +1,6 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import {
AutoMergeBarChart,
type StatData,
@@ -75,7 +76,16 @@ function AllWalletConnectionsChartUI(props: {
isPending={props.isPending}
exportButton={undefined}
maxLabelsToShow={5}
emptyChartState={undefined}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets/configure`,
}}
title="No data available"
description="No wallet connections found in selected time period"
/>
}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
/>
);
@@ -0,0 +1,102 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getUniversalBridgeUsage } from "@/api/analytics";
import {
EmptyChartState,
EmptyChartStateGetStartedCTA,
LoadingChartState,
} from "@/components/analytics/empty-chart-state";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import type { UniversalBridgeStats } from "@/types/analytics";
import { TotalVolumePieChart } from "../payments/components/TotalVolumePieChart";
async function AsyncBridgeCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const data = await getUniversalBridgeUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<BridgeChartCardUI
data={data}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
);
}
export function BridgeChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={<LoadingChartState className="min-h-[40px] h-full border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncBridgeCard {...props} />
</ResponsiveSuspense>
);
}
function BridgeChartCardUI(props: {
data: UniversalBridgeStats[];
teamSlug: string;
projectSlug: string;
}) {
const total =
props.data.reduce((acc, curr) => acc + curr.amountUsdCents, 0) / 100;
return (
<div className="flex flex-col bg-card border rounded-lg">
<TotalValueChartHeader
title="Bridge Volume"
total={total}
isUsd={true}
isPending={false}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/bridge`}
/>
<div className="p-6 grow flex items-center justify-center">
{total === 0 ? (
<EmptyChartState
content={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
}}
title="No data available"
description="No bridge volume found in selected time period"
/>
}
/>
) : (
<TotalVolumePieChart data={props.data} hideTotal={true} />
)}
</div>
</div>
);
}
@@ -1,14 +1,13 @@
"use client";
import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import type { InAppWalletStats, UniversalBridgeStats } from "@/types/analytics";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
@@ -37,29 +36,20 @@ export function ProjectHighlightsCard(props: {
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your app hasn't collected any fees yet."
link={`/team/${teamSlug}/${projectSlug}/bridge/configuration`}
metric="Fees"
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${teamSlug}/${projectSlug}/bridge/configuration`,
}}
title="No data available"
description="No bridge revenue generated in selected time period"
/>
),
isCurrency: true,
label: "Bridge Fee Revenue",
label: "Bridge Revenue",
},
} as const;
@@ -129,14 +119,6 @@ function processTimeSeriesData(
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
.reduce((acc, curr) => acc + curr.newUsers, 0);
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString().slice(0, 10) === date &&
v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
@@ -150,7 +132,6 @@ function processTimeSeriesData(
date: date,
feesCollected: fees,
newUsers: newUsers,
totalVolume: volume,
});
}
@@ -0,0 +1,122 @@
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { ResponsiveSuspense } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import { getInAppWalletUsage, getUniversalBridgeUsage } from "@/api/analytics";
import type { Project } from "@/api/project/projects";
import type { Range } from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { ProjectHighlightsCard } from "./highlights-card-ui";
import type { PageParams, PageSearchParams } from "./types";
export function ProjectHighlightCard(props: {
authToken: string;
client: ThirdwebClient;
interval: "day" | "week";
params: PageParams;
range: Range;
searchParams: PageSearchParams;
project: Project;
}) {
return (
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
>
<AsyncAppHighlightsCard
authToken={props.authToken}
client={props.client}
interval={props.interval}
params={props.params}
project={props.project}
range={props.range}
selectedChart={
typeof props.searchParams.appHighlights === "string"
? props.searchParams.appHighlights
: undefined
}
selectedChartQueryParam="appHighlights"
/>
</ResponsiveSuspense>
);
}
async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
selectedChartQueryParam: string;
selectedChart: string | undefined;
client: ThirdwebClient;
params: PageParams;
authToken: string;
}) {
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getInAppWalletUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getInAppWalletUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getUniversalBridgeUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
]);
if (
walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled"
) {
return (
<ProjectHighlightsCard
aggregatedUserStats={
aggregatedUserStats.status === "fulfilled"
? aggregatedUserStats.value
: []
}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
teamSlug={props.params.team_slug}
projectSlug={props.params.project_slug}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
: []
}
volumeStats={
universalBridgeUsage.status === "fulfilled"
? universalBridgeUsage.value
: []
}
/>
);
}
return (
<EmptyStateCard
link="https://portal.thirdweb.com/wallets"
metric="Wallets"
/>
);
}
@@ -0,0 +1,79 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInsightStatusCodeUsage } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { RequestsByStatusGraph } from "../gateway/indexer/components/RequestsByStatusGraph";
export function IndexerRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={
<RequestsByStatusGraph
data={[]}
isPending={true}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncIndexerRequestsChartCard
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
from={props.from}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
async function AsyncIndexerRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const requestsData = await getInsightStatusCodeUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
return (
<RequestsByStatusGraph
data={requestsData && "data" in requestsData ? requestsData.data : []}
isPending={false}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`,
}}
title="No data available"
description="No indexer requests found in selected time period"
/>
}
/>
);
}
@@ -0,0 +1,144 @@
import { ResponsiveSuspense } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import type { Range } from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { TransactionsChartCardAsync } from "../components/Transactions";
import { AIAnalyticsChartCard } from "./ai-card";
import { AllWalletConnectionsChart } from "./all-wallet-connections-chart";
import { BridgeChartCard } from "./bridge-card";
import { ProjectHighlightCard } from "./highlights-card";
import { IndexerRequestsChartCard } from "./indexer-card";
import { RPCRequestsChartCard } from "./rpc-card";
import type { PageParams, PageSearchParams } from "./types";
import { X402RequestsChartCard } from "./x402-card";
export async function ProjectAnalytics(props: {
project: Project;
params: PageParams;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
authToken: string;
}) {
const { project, params, range, interval, searchParams, client, authToken } =
props;
return (
<div className="flex grow flex-col gap-6">
{/* highlights */}
<ProjectHighlightCard
authToken={authToken}
client={client}
interval={interval}
params={params}
project={project}
range={range}
searchParams={searchParams}
/>
<div className="grid gap-6 md:grid-cols-2">
{/* wallets */}
<AllWalletConnectionsChart
teamSlug={params.team_slug}
projectSlug={params.project_slug}
authToken={authToken}
projectId={project.id}
from={range.from}
to={range.to}
interval={interval}
teamId={project.teamId}
/>
{/* bridge */}
<BridgeChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Indexer */}
<IndexerRequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
{/* RPC */}
<RPCRequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* x402 */}
<X402RequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
{/* AI */}
<AIAnalyticsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</div>
{/* Transactions, Chains, Contracts */}
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
>
<TransactionsChartCardAsync
client={client}
params={{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
}}
authToken={authToken}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
selectedChartQueryParam="client_transactions"
/>
</ResponsiveSuspense>
</div>
);
}
@@ -0,0 +1,81 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getRpcUsageByType } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { RPCRequestsChartUI } from "../gateway/rpc/components/RequestsGraph";
async function AsyncRPCRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const requestsData = await getRpcUsageByType(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
const isEmpty = requestsData?.every((d) => d.count === 0);
return (
<RPCRequestsChartUI
isPending={false}
data={isEmpty ? [] : requestsData || []}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`,
}}
title="No data available"
description="No RPC requests found in selected time period"
/>
}
/>
);
}
export function RPCRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
fallback={
<RPCRequestsChartUI
isPending={true}
data={[]}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncRPCRequestsChartCard
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
from={props.from}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
@@ -0,0 +1,14 @@
export type PageParams = {
team_slug: string;
project_slug: string;
};
export type PageSearchParams = {
from: string | undefined | string[];
to: string | undefined | string[];
type: string | undefined | string[];
interval: string | undefined | string[];
appHighlights: string | undefined | string[];
client_transactions: string | undefined | string[];
totalSponsored: string | undefined | string[];
};
@@ -0,0 +1,93 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { X402RequestsChartCardUI } from "./x402-requests-chart";
async function AsyncX402RequestsChart(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const stats = await getX402Settlements(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
const isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
return (
<X402RequestsChartCardUI
stats={
isAllEmpty
? []
: stats.map((stat) => ({
requests: stat.totalRequests,
time: stat.date,
}))
}
isPending={false}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/x402/configuration`,
}}
title="No data available"
description="No X402 requests found in selected time period"
/>
}
/>
);
}
export function X402RequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
return (
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<X402RequestsChartCardUI
stats={[]}
isPending={true}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
}
>
<AsyncX402RequestsChart
from={props.from}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
to={props.to}
interval={props.interval}
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
);
}
@@ -10,6 +10,7 @@ export function X402RequestsChartCardUI(props: {
isPending: boolean;
teamSlug: string;
projectSlug: string;
emptyChartState?: React.ReactElement | undefined;
}) {
const total = useMemo(() => {
return props.stats.reduce((acc, curr) => acc + curr.requests, 0);
@@ -41,6 +42,7 @@ export function X402RequestsChartCardUI(props: {
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/x402`}
/>
}
emptyChartState={props.emptyChartState}
/>
);
}
@@ -20,6 +20,7 @@ export function AiTokenUsageChartCardUI(props: {
isPending: boolean;
title: string;
viewMoreLink: string | undefined;
emptyChartState?: React.ReactElement | undefined;
}) {
const { aiUsageStats } = props;
@@ -93,7 +94,7 @@ export function AiTokenUsageChartCardUI(props: {
)
}
data={chartData}
emptyChartState={<AiTokenUsageEmptyChartState />}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend={false}
@@ -109,7 +110,7 @@ export function AiTokenUsageChartCardUI(props: {
);
}
function AiTokenUsageEmptyChartState() {
function _AiTokenUsageEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<h3 className="text-base font-semibold text-foreground mb-1 text-center">
@@ -60,7 +60,7 @@ export function QuickStartSection(props: {
id="webhooks"
features={["Instant events", "Transaction verification"]}
link={{
href: `/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`,
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`,
label: "Setup Webhooks",
}}
/>
@@ -1,4 +1,3 @@
import { WebhookIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
@@ -43,13 +42,7 @@ export default async function Layout(props: {
instantly
</>
),
actions: {
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
},
actions: null,
links: [
{
type: "docs",
@@ -75,6 +68,10 @@ export default async function Layout(props: {
name: "Configuration",
path: `/team/${params.team_slug}/${params.project_slug}/bridge/configuration`,
},
{
name: "Webhooks",
path: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
},
]}
>
{props.children}
@@ -46,7 +46,7 @@ export default async function Page(props: {
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href={`/team/${params.team_slug}/${params.project_slug}/webhooks/payments`}
href={`/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`}
>
Webhooks
</Link>
@@ -12,6 +12,7 @@ import {
PlusIcon,
TrashIcon,
WebhookIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { type PropsWithChildren, useMemo, useState } from "react";
@@ -95,9 +96,14 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
if (!webhooksQuery.data?.length) {
return (
<div className="flex flex-col items-center gap-6 rounded-lg border border-border bg-card p-8 text-center">
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-xl">No webhooks configured</h2>
<p className="text-sm text-muted-foreground">
<div className="flex flex-col items-center">
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
<XIcon className="size-4 text-muted-foreground" />
</div>
<h2 className="font-semibold text-base mb-1">
No webhooks configured
</h2>
<p className="text-muted-foreground text-sm">
Create a webhook to receive notifications for bridge, swap or onramp
events.
</p>
@@ -126,7 +132,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
webhook={webhook}
clientId={props.clientId}
teamId={props.teamId}
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`}
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`}
/>
))}
</div>
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { PayWebhooksPage } from "../../payments/webhooks/components/webhooks.client";
import { PayWebhooksPage } from "./components/webhooks.client";
export default async function Page(props: {
params: Promise<{
@@ -6,7 +6,6 @@ import {
DoorOpenIcon,
HomeIcon,
Settings2Icon,
WebhookIcon,
} from "lucide-react";
import {
FullWidthSidebarLayout,
@@ -136,14 +135,6 @@ export function ProjectSidebarLayout(props: {
{
separator: true,
},
{
href: `${props.layoutPath}/webhooks/contracts`,
icon: WebhookIcon,
isActive: (pathname) => {
return pathname.startsWith(`${props.layoutPath}/webhooks`);
},
label: "Webhooks",
},
{
href: `${props.layoutPath}/settings`,
icon: Settings2Icon,
@@ -3,6 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ArrowLeftRightIcon,
EllipsisVerticalIcon,
RefreshCcwIcon,
@@ -10,17 +11,22 @@ import {
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
createThirdwebClient,
Engine,
getContract,
readContract,
type ThirdwebClient,
toUnits,
} from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import type { Chain } from "thirdweb/chains";
import { SwapWidget, useWalletBalance } from "thirdweb/react";
import { isAddress, shortenAddress } from "thirdweb/utils";
import { createWalletAdapter } from "thirdweb/wallets";
import { z } from "zod";
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
import type { Project } from "@/api/project/projects";
@@ -69,6 +75,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
type GetProjectServerWallets = (params: {
@@ -127,6 +134,11 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
const [isSendOpen, setIsSendOpen] = useState(false);
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
const [isSwapOpen, setIsSwapOpen] = useState(false);
// Persist swap credentials in memory so users don't have to re-enter them
const [swapSecretKey, setSwapSecretKey] = useState("");
const [swapVaultAccessToken, setSwapVaultAccessToken] = useState("");
// Initialize chain and token from localStorage or defaults
const [selectedChainId, setSelectedChainId] = useState(() => {
@@ -310,6 +322,15 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
<SendIcon className="size-4" />
Withdraw
</Button>
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSwapOpen(true)}
>
<ArrowLeftRightIcon className="size-4" />
Swap
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -394,6 +415,25 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
/>
</DialogContent>
</Dialog>
<Dialog onOpenChange={setIsSwapOpen} open={isSwapOpen}>
<DialogContent className="gap-0 p-0 overflow-hidden max-w-md">
<SwapProjectWalletModalContent
client={props.client}
chainId={selectedChainId}
tokenAddress={selectedTokenAddress}
walletAddress={projectWallet.address}
chain={chain}
isManagedVault={isManagedVault}
publishableKey={project.publishableKey}
secretKey={swapSecretKey}
setSecretKey={setSwapSecretKey}
vaultAccessToken={swapVaultAccessToken}
setVaultAccessToken={setSwapVaultAccessToken}
onClose={() => setIsSwapOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
);
}
@@ -573,6 +613,192 @@ function ChangeProjectWalletDialogContent(props: {
);
}
type SwapProjectWalletModalContentProps = {
client: ThirdwebClient;
chainId: number;
tokenAddress: string | undefined;
walletAddress: string;
chain: Chain;
isManagedVault: boolean;
publishableKey: string;
secretKey: string;
setSecretKey: (value: string) => void;
vaultAccessToken: string;
setVaultAccessToken: (value: string) => void;
onClose: () => void;
};
function SwapProjectWalletModalContent(
props: SwapProjectWalletModalContentProps,
) {
const {
client,
chainId,
tokenAddress,
walletAddress,
chain,
isManagedVault,
publishableKey,
secretKey,
setSecretKey,
vaultAccessToken,
setVaultAccessToken,
onClose,
} = props;
const [screen, setScreen] = useState<"credentials" | "swap">("credentials");
const { theme } = useTheme();
const t = theme === "light" ? "light" : "dark";
const hasRequiredCredentials = isManagedVault
? secretKey.trim().length > 0
: secretKey.trim().length > 0 && vaultAccessToken.trim().length > 0;
const swapClient = useMemo(() => {
if (!secretKey.trim()) {
return null;
}
// use the inputted secret key to create the client used for the serverWallet
return createThirdwebClient({
clientId: publishableKey,
secretKey: secretKey.trim(),
});
}, [secretKey, publishableKey]);
const activeWallet = useMemo(() => {
if (!swapClient) {
return undefined;
}
const vaultAccessTokenValue = vaultAccessToken.trim();
return createWalletAdapter({
adaptedAccount: Engine.serverWallet({
client: swapClient,
address: walletAddress,
...(vaultAccessTokenValue
? { vaultAccessToken: vaultAccessTokenValue }
: {}),
}),
chain: chain,
client: swapClient,
onDisconnect: () => {},
switchChain: () => {},
});
}, [swapClient, walletAddress, chain, vaultAccessToken]);
// Screen 1: Credentials
if (screen === "credentials") {
return (
<div>
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Enter your credentials to swap tokens from your project wallet
</DialogDescription>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="swap-secret-key"
className="text-sm font-medium leading-none"
>
Project secret key
</label>
<Input
id="swap-secret-key"
type="password"
placeholder="Enter your project secret key"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">{secretKeyHelper}</p>
</div>
{!isManagedVault && (
<div className="space-y-2">
<label
htmlFor="swap-vault-access-token"
className="text-sm font-medium leading-none"
>
Vault access token
</label>
<Input
id="swap-vault-access-token"
type="password"
placeholder="Enter a vault access token"
value={vaultAccessToken}
onChange={(e) => setVaultAccessToken(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
{vaultAccessTokenHelper}
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4">
<Button onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button
onClick={() => setScreen("swap")}
type="button"
disabled={!hasRequiredCredentials}
>
Continue
</Button>
</div>
</div>
);
}
// Screen 2: Swap Widget
return (
<div className="w-full">
<DialogHeader className="p-4 lg:p-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="p-1 h-auto w-auto"
onClick={() => setScreen("credentials")}
>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Swap tokens from your project wallet
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 flex justify-center">
{activeWallet && (
<SwapWidget
client={props.client}
prefill={{
sellToken: {
chainId: chainId,
tokenAddress: tokenAddress,
},
}}
activeWallet={activeWallet}
theme={getSDKTheme(t)}
/>
)}
</div>
</div>
);
}
const createSendFormSchema = (secretKeyLabel: string) =>
z.object({
chainId: z.number({
@@ -98,7 +98,7 @@ export default async function Page(props: {
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/contracts",
href: "https://api.thirdweb.com/reference#tag/gateway",
},
{
type: "webhooks",
@@ -3,7 +3,6 @@ import { format } from "date-fns";
import { useMemo } from "react";
import { shortenLargeNumber } from "thirdweb/utils";
import type { InsightStatusCodeStats } from "@/api/analytics";
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import type { ChartConfig } from "@/components/ui/chart";
@@ -17,6 +16,7 @@ export function RequestsByStatusGraph(props: {
data: InsightStatusCodeStats[];
isPending: boolean;
viewMoreLink: string | undefined;
emptyChartState?: React.ReactElement | undefined;
}) {
const topStatusCodesToShow = 10;
@@ -105,7 +105,7 @@ export function RequestsByStatusGraph(props: {
/>
}
data={chartData}
emptyChartState={<EmptyChartState type="bar" />}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend
@@ -10,6 +10,7 @@ export function RPCRequestsChartUI(props: {
data: RpcUsageTypeStats[];
viewMoreLink: string | undefined;
isPending: boolean;
emptyChartState?: React.ReactElement | undefined;
}) {
const total = useMemo(() => {
return props.data.reduce((acc, curr) => acc + curr.count, 0);
@@ -73,6 +74,7 @@ export function RPCRequestsChartUI(props: {
xAxis={{
showHour: showBreakdownByHour,
}}
emptyChartState={props.emptyChartState}
/>
);
}
@@ -1,26 +1,9 @@
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { redirect } from "next/navigation";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
} from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import {
getAiUsage,
getInAppWalletUsage,
getInsightStatusCodeUsage,
getRpcUsageByType,
getUniversalBridgeUsage,
getX402Settlements,
isProjectActive,
} from "@/api/analytics";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { isProjectActive } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject, type Project } from "@/api/project/projects";
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
@@ -28,39 +11,16 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { AiTokenUsageChartCardUI } from "./ai/analytics/chart/AiTokenUsageChartCard";
import { ProjectAnalytics } from "./_analytics/project-analytics";
import type { PageParams, PageSearchParams } from "./_analytics/types";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { ProjectWalletSection } from "./components/project-wallet/project-wallet";
import { TransactionsChartCardAsync } from "./components/Transactions";
import { RequestsByStatusGraph } from "./gateway/indexer/components/RequestsByStatusGraph";
import { RPCRequestsChartUI } from "./gateway/rpc/components/RequestsGraph";
import { ProjectHighlightsCard } from "./overview/highlights-card";
import { AllWalletConnectionsChart } from "./wallets/analytics/chart/all-wallet-connections-chart";
import { X402RequestsChartCardUI } from "./x402/analytics/x402-requests-chart";
type PageParams = {
team_slug: string;
project_slug: string;
};
type PageSearchParams = {
from: string | undefined | string[];
to: string | undefined | string[];
type: string | undefined | string[];
interval: string | undefined | string[];
appHighlights: string | undefined | string[];
client_transactions: string | undefined | string[];
totalSponsored: string | undefined | string[];
};
type PageProps = {
params: Promise<PageParams>;
searchParams: Promise<PageSearchParams>;
};
// Revalidate this page data every 30 seconds
export const revalidate = 30;
export default async function ProjectOverviewPage(props: PageProps) {
const [params, searchParams] = await Promise.all([
props.params,
@@ -104,25 +64,25 @@ export default async function ProjectOverviewPage(props: PageProps) {
const projectWallet = await getProjectWallet(project);
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<ProjectPage
header={{
client,
isProjectIcon: true,
icon: () => (
<ProjectAvatar
className="size-11"
client={client}
src={project.image ?? ""}
/>
),
title: project.name,
description: "Your project's overview and analytics",
// explicitly NO actions on the overview page
actions: null,
}}
>
{isActive ? (
<ProjectPage
header={{
client,
isProjectIcon: true,
icon: () => (
<ProjectAvatar
className="size-11"
client={client}
src={project.image ?? ""}
/>
),
title: project.name,
description: "Your project's overview and analytics",
// explicitly NO actions on the overview page
actions: null,
}}
>
{isActive ? (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-10">
<ProjectWalletSection
project={project}
@@ -144,413 +104,24 @@ export default async function ProjectOverviewPage(props: PageProps) {
/>
</div>
</div>
) : (
<div className="pt-6">
<ProjectFTUX
project={project}
teamSlug={params.team_slug}
projectWalletSection={
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
projectWallet={projectWallet}
client={client}
layout="row"
/>
}
/>
</div>
)}
</ProjectPage>
</ResponsiveSearchParamsProvider>
);
}
async function ProjectAnalytics(props: {
project: Project;
params: PageParams;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
authToken: string;
}) {
const { project, params, range, interval, searchParams, client, authToken } =
props;
return (
<div className="flex grow flex-col gap-6">
{/* project highlights */}
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
>
<AsyncAppHighlightsCard
authToken={authToken}
client={client}
interval={interval}
params={params}
project={project}
range={range}
selectedChart={
typeof searchParams.appHighlights === "string"
? searchParams.appHighlights
: undefined
}
selectedChartQueryParam="appHighlights"
/>
</ResponsiveSuspense>
{/* wallet connections */}
<AllWalletConnectionsChart
teamSlug={params.team_slug}
projectSlug={params.project_slug}
authToken={authToken}
projectId={project.id}
from={range.from}
to={range.to}
interval={interval}
teamId={project.teamId}
/>
{/* indexer and RPC requests */}
<div className="grid gap-6 md:grid-cols-2">
<ResponsiveSuspense
fallback={
<RequestsByStatusGraph
data={[]}
isPending={true}
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/indexer`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncIndexerRequestsChartCard
</ResponsiveSearchParamsProvider>
) : (
<div className="pt-6">
<ProjectFTUX
project={project}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
projectWalletSection={
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
projectWallet={projectWallet}
client={client}
layout="row"
/>
}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={
<RPCRequestsChartUI
isPending={true}
data={[]}
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/rpc`}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncRPCRequestsChartCard
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* x402 */}
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<X402RequestsChartCardUI
stats={[]}
isPending={true}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
/>
}
>
<AsyncX402RequestsChart
from={range.from}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSuspense>
{/* AI tokens */}
<ResponsiveSuspense
fallback={
<AiTokenUsageChartCardUI
isPending={true}
title="AI token volume"
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/ai/analytics`}
aiUsageStats={[]}
/>
}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAiAnalytics
teamSlug={params.team_slug}
projectSlug={params.project_slug}
from={range.from}
to={range.to}
interval={interval}
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
{/* transactions */}
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
>
<TransactionsChartCardAsync
client={client}
params={{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
}}
authToken={authToken}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
selectedChartQueryParam="client_transactions"
/>
</ResponsiveSuspense>
</div>
);
}
async function AsyncIndexerRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const requestsData = await getInsightStatusCodeUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
return (
<RequestsByStatusGraph
data={requestsData && "data" in requestsData ? requestsData.data : []}
isPending={false}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
/>
);
}
async function AsyncRPCRequestsChartCard(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const requestsData = await getRpcUsageByType(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch(() => undefined);
return (
<RPCRequestsChartUI
isPending={false}
data={requestsData || []}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
/>
);
}
async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
selectedChartQueryParam: string;
selectedChart: string | undefined;
client: ThirdwebClient;
params: PageParams;
authToken: string;
}) {
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getInAppWalletUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getInAppWalletUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getUniversalBridgeUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
]);
if (
walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled"
) {
return (
<ProjectHighlightsCard
aggregatedUserStats={
aggregatedUserStats.status === "fulfilled"
? aggregatedUserStats.value
: []
}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
teamSlug={props.params.team_slug}
projectSlug={props.params.project_slug}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
: []
}
volumeStats={
universalBridgeUsage.status === "fulfilled"
? universalBridgeUsage.value
: []
}
/>
);
}
return (
<EmptyStateCard
link="https://portal.thirdweb.com/wallets"
metric="Wallets"
/>
);
}
async function AsyncAiAnalytics(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const stats = await getAiUsage(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<AiTokenUsageChartCardUI
title="AI token volume"
isPending={false}
aiUsageStats={stats}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
/>
);
}
async function AsyncX402RequestsChart(props: {
from: Date;
to: Date;
interval: "day" | "week";
projectId: string;
teamId: string;
authToken: string;
teamSlug: string;
projectSlug: string;
}) {
const stats = await getX402Settlements(
{
from: props.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: props.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
const isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
return (
<X402RequestsChartCardUI
stats={
isAllEmpty
? []
: stats.map((stat) => ({
requests: stat.totalRequests,
time: stat.date,
}))
}
isPending={false}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
</div>
)}
</ProjectPage>
);
}
@@ -39,7 +39,7 @@ export function AdvancedSection({
id="webhooks"
features={["Instant events", "Transaction verification"]}
link={{
href: `/team/${teamSlug}/${projectSlug}/webhooks/payments`,
href: `/team/${teamSlug}/${projectSlug}/bridge/webhooks`,
label: "Setup Webhooks",
}}
/>
@@ -67,24 +67,9 @@ export async function PayAnalytics(props: {
walletDataPromise,
]);
const hasVolume = volumeData.some((d) => d.amountUsdCents > 0);
const hasWallet = walletData.some((d) => d.count > 0);
if (!hasVolume && !hasWallet) {
return null;
}
return (
<div>
<div>
<div>
<h2 className="text-xl font-semibold tracking-tight mb-1">
Analytics
</h2>
<p className="text-muted-foreground mb-4 text-sm">
Track Bridge volume, customers, payouts, and success rates.
</p>
</div>
<div className="mb-4 flex justify-start">
<PayAnalyticsFilter />
</div>
@@ -12,7 +12,10 @@ type VolData = {
color: string;
};
export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
export function TotalVolumePieChart(props: {
data: UniversalBridgeStats[];
hideTotal?: boolean;
}) {
const data = props.data;
const isEmpty =
data.length === 0 || data.every((x) => x.amountUsdCents === 0);
@@ -36,20 +39,20 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
.filter((x) => x.type === "onramp")
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const volumeData: VolData[] = !isEmpty
? [
{
amount: cryptoTotalUSD,
color: "hsl(var(--chart-1))",
name: "Crypto",
},
{
amount: fiatTotalUSD,
color: "hsl(var(--chart-2))",
name: "Fiat",
},
]
: skeletonData;
const volumeData: VolData[] = [
{
amount: cryptoTotalUSD,
color: "hsl(var(--chart-1))",
name: "Crypto",
},
{
amount: fiatTotalUSD,
color: "hsl(var(--chart-2))",
name: "Fiat",
},
];
const chartVolumeData = !isEmpty ? volumeData : skeletonData;
return (
<section className="w-full">
@@ -62,7 +65,7 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
cornerRadius={100}
cx="50%"
cy="50%"
data={volumeData}
data={chartVolumeData}
dataKey="amount"
innerRadius="80%"
outerRadius="100%"
@@ -72,41 +75,45 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
outline: "none",
}}
>
{volumeData.map((entry, index) => (
{chartVolumeData.map((entry, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<Cell fill={entry.color} key={index} />
))}
</Pie>
</PieChart>
<div className="absolute inset-0 flex items-center justify-center p-10">
<div className="flex flex-col items-center gap-1">
<p className="font-medium text-sm"> Total Volume</p>
{!props.hideTotal && (
<div className="absolute inset-0 flex items-center justify-center p-10">
<div className="flex flex-col items-center gap-1">
<p className="font-medium text-sm text-muted-foreground">
Total Volume
</p>
<SkeletonContainer
loadedData={
!data
? undefined
: data.length > 0
? toUSD(cryptoTotalUSD + fiatTotalUSD)
: "NA"
}
render={(totalAmount) => {
return (
<p
className={cn(
"font-semibold text-3xl tracking-tighter",
totalAmount.length > 6 ? "text-3xl" : "text-4xl",
)}
>
{totalAmount}
</p>
);
}}
skeletonData="$100"
/>
<SkeletonContainer
loadedData={
!data
? undefined
: data.length > 0
? toUSD(cryptoTotalUSD + fiatTotalUSD)
: "NA"
}
render={(totalAmount) => {
return (
<p
className={cn(
"font-semibold text-2xl tracking-tighter",
totalAmount.length > 6 ? "text-2xl" : "text-3xl",
)}
>
{totalAmount}
</p>
);
}}
skeletonData="$100"
/>
</div>
</div>
</div>
)}
</div>
{/* Right */}
<div className="items-center border-border border-t pt-5 lg:flex lg:border-none lg:pt-0 lg:pr-10">
@@ -114,9 +121,9 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
{volumeData.map((v) => (
<VolumeLegend
amount={
!data ? undefined : data.length > 0 ? toUSD(v.amount) : "NA"
isEmpty ? "NA" : data.length > 0 ? toUSD(v.amount) : "NA"
}
color={v.color}
color={isEmpty ? "hsl(var(--muted))" : v.color}
key={v.name}
label={v.name}
/>
@@ -66,7 +66,7 @@ export default async function Page(props: {
),
},
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
href: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
@@ -85,7 +85,7 @@ export default async function Page(props: {
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/payments",
href: "https://api.thirdweb.com/reference#tag/x402",
},
],
}}
@@ -1,16 +1,10 @@
import { Button } from "@workspace/ui/components/button";
import { PlusIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken } from "@/api/auth-token";
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { ClientOnly } from "@/components/blocks/client-only";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ContractTable } from "@/components/contract-components/tables/contract-table";
import {
Dialog,
DialogContent,
@@ -21,10 +15,11 @@ import {
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TokenIcon } from "@/icons/TokenIcon";
import { loginRedirect } from "@/utils/redirects";
import { Cards, ImportTokenButton } from "./cards";
import { Cards, ImportTokenButton } from "../cards";
export default async function Page(props: {
export default async function Layout(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
children: React.ReactNode;
}) {
const params = await props.params;
@@ -110,48 +105,19 @@ export default async function Page(props: {
},
],
}}
tabs={[
{
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/tokens`,
exactMatch: true,
},
{
name: "Webhooks",
path: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks`,
},
]}
>
<Suspense fallback={<GenericLoadingPage />}>
<AssetsPageAsync
authToken={authToken}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
teamSlug={params.team_slug}
/>
</Suspense>
{props.children}
</ProjectPage>
);
}
async function AssetsPageAsync(props: {
teamId: string;
projectId: string;
authToken: string;
client: ThirdwebClient;
teamSlug: string;
projectSlug: string;
}) {
const deployedContracts = await getFilteredProjectContracts({
authToken: props.authToken,
type: "token-contracts",
projectId: props.projectId,
teamId: props.teamId,
});
return (
<ClientOnly ssr={<GenericLoadingPage />}>
<ContractTable
client={props.client}
contracts={deployedContracts}
pageSize={10}
projectId={props.projectId}
projectSlug={props.projectSlug}
teamId={props.teamId}
teamSlug={props.teamSlug}
variant="asset"
/>
</ClientOnly>
);
}
@@ -0,0 +1,84 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken } from "@/api/auth-token";
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { ClientOnly } from "@/components/blocks/client-only";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ContractTable } from "@/components/contract-components/tables/contract-table";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const [authToken, team, project] = await Promise.all([
getAuthToken(),
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/tokens`);
}
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<Suspense fallback={<GenericLoadingPage />}>
<AssetsPageAsync
authToken={authToken}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
teamSlug={params.team_slug}
/>
</Suspense>
);
}
async function AssetsPageAsync(props: {
teamId: string;
projectId: string;
authToken: string;
client: ThirdwebClient;
teamSlug: string;
projectSlug: string;
}) {
const deployedContracts = await getFilteredProjectContracts({
authToken: props.authToken,
type: "token-contracts",
projectId: props.projectId,
teamId: props.teamId,
});
return (
<ClientOnly ssr={<GenericLoadingPage />}>
<ContractTable
client={props.client}
contracts={deployedContracts}
pageSize={10}
projectId={props.projectId}
projectSlug={props.projectSlug}
teamId={props.teamId}
teamSlug={props.teamSlug}
variant="asset"
/>
</ClientOnly>
);
}
@@ -13,7 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
import type { WebhookFormValues } from "../utils/webhookTypes";
import type { WebhookFormValues } from "./utils/webhookTypes";
interface BasicInfoStepProps {
form: UseFormReturn<WebhookFormValues>;
@@ -16,26 +16,26 @@ import {
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useAbiMultiFetch } from "../hooks/useAbiProcessing";
import { useTestWebhook } from "../hooks/useTestWebhook";
import BasicInfoStep from "./BasicInfoStep";
import { FilterDetailsStep } from "./FilterDetailsStep";
import ReviewStep from "./ReviewStep";
import { useAbiMultiFetch } from "./useAbiProcessing";
import { useTestWebhook } from "./useTestWebhook";
import {
extractEventSignatures,
extractFunctionSignatures,
} from "../utils/abiUtils";
import type { WebhookPayload } from "../utils/webhookPayloadUtils";
} from "./utils/abiUtils";
import type { WebhookPayload } from "./utils/webhookPayloadUtils";
import {
buildEventWebhookPayload,
buildTransactionWebhookPayload,
} from "../utils/webhookPayloadUtils";
} from "./utils/webhookPayloadUtils";
import {
type WebhookFormStep,
WebhookFormSteps,
type WebhookFormValues,
webhookFormSchema,
} from "../utils/webhookTypes";
import BasicInfoStep from "./BasicInfoStep";
import { FilterDetailsStep } from "./FilterDetailsStep";
import ReviewStep from "./ReviewStep";
} from "./utils/webhookTypes";
interface CreateWebhookModalProps {
projectClientId: string;
@@ -17,13 +17,13 @@ import {
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { truncateMiddle } from "../utils/abiUtils";
import { truncateMiddle } from "./utils/abiUtils";
import type {
AbiData,
EventSignature,
FunctionSignature,
WebhookFormValues,
} from "../utils/webhookTypes";
} from "./utils/webhookTypes";
interface FilterDetailsStepProps {
form: UseFormReturn<WebhookFormValues>;
@@ -3,8 +3,8 @@ import { CheckIcon, PlayIcon, XIcon } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner";
import { truncateMiddle } from "../utils/abiUtils";
import type { WebhookFormValues } from "../utils/webhookTypes";
import { truncateMiddle } from "./utils/abiUtils";
import type { WebhookFormValues } from "./utils/webhookTypes";
interface WebhookTestResult {
status: string;
@@ -17,9 +17,9 @@ import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useTestWebhook } from "../hooks/useTestWebhook";
import { CreateContractWebhookButton } from "./CreateWebhookModal";
import { RelativeTime } from "./RelativeTime";
import { useTestWebhook } from "./useTestWebhook";
function getEventType(filters: WebhookFilters): string {
if (!filters || typeof filters !== "object") {
@@ -1,8 +1,9 @@
import { XIcon } from "lucide-react";
import { getWebhooks, type WebhookResponse } from "@/api/insight/webhooks";
import type { Project } from "@/api/project/projects";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { CreateContractWebhookButton } from "../components/CreateWebhookModal";
import { ContractsWebhooksTable } from "../components/WebhooksTable";
import { CreateContractWebhookButton } from "./CreateWebhookModal";
import { ContractsWebhooksTable } from "./WebhooksTable";
export async function ContractsWebhooksPageContent(props: {
project: Project;
@@ -50,11 +51,14 @@ export async function ContractsWebhooksPageContent(props: {
webhooks={webhooks}
/>
) : (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border p-12 text-center">
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border py-14 px-6 text-center">
<div>
<h3 className="mb-1 font-medium text-lg">No webhooks found</h3>
<p className="text-muted-foreground">
Create a webhook to get started.
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
<XIcon className="size-4 text-muted-foreground" />
</div>
<h3 className="mb-1 font-semibold text-base">No webhooks found</h3>
<p className="text-muted-foreground text-sm">
Create a webhook to get started
</p>
</div>
<CreateContractWebhookButton
@@ -5,12 +5,12 @@ import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getContract, resolveAbiFromContractApi } from "thirdweb/contract";
import { useGetV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { parseAddresses } from "../utils/webhookPayloadUtils";
import { parseAddresses } from "./utils/webhookPayloadUtils";
import type {
AbiData,
EventSignature,
FunctionSignature,
} from "../utils/webhookTypes";
} from "./utils/webhookTypes";
export function useAbiMultiFetch({
isOpen,
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getSupportedWebhookChains } from "@/api/insight/webhooks";
import { getProject } from "@/api/project/projects";
import { ContractsWebhooksPageContent } from "../contract-webhooks/contract-webhooks-page";
import { ContractsWebhooksPageContent } from "./components/contract-webhooks-page";
export default async function ContractsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -1,57 +0,0 @@
import { WebhookIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
children: React.ReactNode;
}) {
const [authToken, params] = await Promise.all([getAuthToken(), props.params]);
const project = await getProject(params.team_slug, params.project_slug);
if (!project || !authToken) {
notFound();
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
icon: WebhookIcon,
title: "Webhooks",
description: "Get notified for Bridge, Swap and Onramp events.",
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/bridge/webhooks",
},
],
}}
tabs={[
{
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Bridge",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -1,12 +0,0 @@
import { redirect } from "next/navigation";
export default async function WebhooksPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
// for the moment redirect to the "/contracts webhooks"
redirect(
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
);
}
@@ -22,7 +22,7 @@ export function X402EmptyState(props: { walletAddress?: string }) {
label: "View Docs",
},
]}
title="Start Monetizing your API"
title="Payment gate your API"
/>
);
}
@@ -49,7 +49,7 @@ export async function GET(request: Request) {
price: "$0.01",
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// Payment successful, continue to app logic
return Response.json({ data: "premium content" });
@@ -4,7 +4,6 @@ import {
CartesianGrid,
BarChart as RechartsBarChart,
XAxis,
YAxis,
} from "recharts";
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
import {
@@ -13,6 +12,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { cn } from "@/lib/utils";
import { formatTickerNumber } from "@/utils/format-utils";
import { toUSD } from "@/utils/number";
@@ -23,6 +23,7 @@ export function BarChart({
tooltipLabel,
isCurrency = false,
emptyChartContent,
chartContentClassName,
}: {
chartConfig: ChartConfig;
data: { [key in string]: number | string }[];
@@ -30,10 +31,11 @@ export function BarChart({
tooltipLabel?: string;
isCurrency?: boolean;
emptyChartContent?: React.ReactNode;
chartContentClassName?: string;
}) {
return (
<ChartContainer
className="aspect-auto h-[250px] w-full pt-6"
className={cn("aspect-auto h-[275px] w-full pt-6", chartContentClassName)}
config={{
[activeKey]: {
label: tooltipLabel ?? chartConfig[activeKey]?.label,
@@ -42,16 +44,11 @@ export function BarChart({
}}
>
{data.length === 0 || data.every((d) => d[activeKey] === 0) ? (
<EmptyChartState type="bar"> {emptyChartContent} </EmptyChartState>
<div className="h-full">
<EmptyChartState content={emptyChartContent} />
</div>
) : (
<RechartsBarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
>
<RechartsBarChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
@@ -67,13 +64,7 @@ export function BarChart({
tickLine={false}
tickMargin={8}
/>
<YAxis
axisLine={false}
dataKey={activeKey}
tickFormatter={(value: number) => formatTickerNumber(value)}
tickLine={false}
width={48}
/>
<ChartTooltip
content={
<ChartTooltipContent
@@ -83,7 +83,7 @@ export function CombinedBarChartCard<
</div>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6 sm:pl-0">
<CardContent className="">
<BarChart
activeKey={activeChart}
chartConfig={chartConfig}
@@ -8,13 +8,16 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart";
import { formatTickerNumber } from "@/utils/format-utils";
import { toUSD } from "@/utils/number";
export function PieChart({
title,
data,
isCurrency = false,
}: {
title: string;
data: { value: number; label: string; fill?: string }[];
isCurrency?: boolean;
}) {
const chartConfig: ChartConfig = Object.fromEntries(
Object.entries(data).map(([name, value]) => [
@@ -42,7 +45,11 @@ export function PieChart({
content={
<ChartTooltipContent
hideLabel
valueFormatter={(v: unknown) => formatTickerNumber(v as number)}
valueFormatter={(v: unknown) =>
isCurrency
? toUSD(v as number)
: formatTickerNumber(v as number)
}
/>
}
cursor={false}
@@ -1,4 +1,9 @@
import Link from "next/link";
import { useMemo } from "react";
import {
EmptyChartState,
LoadingChartState,
} from "@/components/analytics/empty-chart-state";
import {
Card,
CardContent,
@@ -18,10 +23,18 @@ export function PieChartCard({
title,
data,
aggregateFn = (data) => data.reduce((acc, curr) => acc + curr.value, 0),
isCurrency = false,
customHeader,
isPending = false,
emptyChartState,
}: {
title: string;
data: ChartData[];
aggregateFn?: (data: ChartData[]) => number;
isCurrency?: boolean;
customHeader?: React.ReactNode;
isPending?: boolean;
emptyChartState?: React.ReactNode;
}) {
const processedData = (() => {
// Sort by value descending
@@ -53,48 +66,69 @@ export function PieChartCard({
fill: item.fill || `hsl(var(--chart-${index + 1}))`,
}));
const isAllEmpty = useMemo(() => {
return data.every((item) => item.value === 0);
}, [data]);
return (
<Card className="flex flex-col">
<CardHeader className="border-border border-b p-0">
<Stat label={title} value={aggregateFn(data)} />
</CardHeader>
{customHeader || (
<CardHeader className="border-border border-b p-0">
<Stat label={title} value={aggregateFn(data)} />
</CardHeader>
)}
<CardContent className="flex-1 p-4">
<PieChart data={processedData} title={title} />
{isPending ? (
<LoadingChartState />
) : isAllEmpty ? (
<EmptyChartState content={emptyChartState} />
) : (
<PieChart
data={processedData}
title={title}
isCurrency={isCurrency}
/>
)}
</CardContent>
<CardFooter className="no-scrollbar flex max-w-full justify-center p-6 pt-0 max-md:overflow-x-auto">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2.5">
{processedData.map(
({
label,
fill,
link,
}: {
label: string;
fill?: string;
link?: string;
}) => (
<div className="flex items-center gap-2" key={fill}>
<div
className="size-2 rounded-full"
style={{ background: fill }}
/>
{link ? (
<Link
className="text-muted-foreground text-xs hover:text-link-foreground"
href={link}
rel="noopener noreferrer"
target="_blank"
>
{label}
</Link>
) : (
<span className="text-muted-foreground text-xs">{label}</span>
)}
</div>
),
)}
</div>
</CardFooter>
{!isAllEmpty && (
<CardFooter className="no-scrollbar flex max-w-full justify-center p-6 pt-0 max-md:overflow-x-auto">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2.5">
{processedData.map(
({
label,
fill,
link,
}: {
label: string;
fill?: string;
link?: string;
}) => (
<div className="flex items-center gap-2" key={fill}>
<div
className="size-2 rounded-full"
style={{ background: fill }}
/>
{link ? (
<Link
className="text-muted-foreground text-xs hover:text-link-foreground"
href={link}
rel="noopener noreferrer"
target="_blank"
>
{label}
</Link>
) : (
<span className="text-muted-foreground text-xs">
{label}
</span>
)}
</div>
),
)}
</div>
</CardFooter>
)}
</Card>
);
}
+83 -83
View File
@@ -1,85 +1,85 @@
{
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "1.1.14",
"@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-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",
"@tanstack/react-query": "5.81.5",
"@vercel/functions": "2.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-dom": "19.1.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/eslint-plugin-next": "15.3.5",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/node": "22.14.1",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"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.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "nebula-app",
"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": "0.0.2"
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "1.1.14",
"@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-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",
"@tanstack/react-query": "5.81.5",
"@vercel/functions": "2.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-dom": "19.2.1",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chromatic-com/storybook": "4.0.1",
"@next/eslint-plugin-next": "15.3.6",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/node": "22.14.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"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.6",
"storybook": "9.0.15",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
},
"name": "nebula-app",
"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": "0.0.2"
}
+83 -83
View File
@@ -1,85 +1,85 @@
{
"dependencies": {
"@abstract-foundation/agw-react": "^1.10.0",
"@ai-sdk/react": "^2.0.25",
"@hookform/resolvers": "^3.9.1",
"@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-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",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@scalar/api-reference-react": "^0.7.42",
"@tanstack/react-query": "5.81.5",
"@thirdweb-dev/ai-sdk-provider": "workspace:*",
"@workspace/ui": "workspace:*",
"ai": "^5.0.25",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"geist": "^1.5.1",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"openapi-types": "12.1.3",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-dom": "19.1.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-pick-color": "^2.0.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"streamdown": "^1.1.4",
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
"use-stick-to-bottom": "^1.1.1",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@types/node": "22.14.1",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-config-next": "15.3.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"knip": "5.60.2",
"postcss": "8.5.6",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.8.3"
},
"name": "playground-web",
"private": true,
"scripts": {
"build": "next build",
"dev": "rm -rf .next && next dev --turbopack",
"fix": "eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"prefix": "biome check ./src --fix",
"prelint": "biome check ./src",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"version": "0.1.1"
"dependencies": {
"@abstract-foundation/agw-react": "^1.10.0",
"@ai-sdk/react": "^2.0.25",
"@hookform/resolvers": "^3.9.1",
"@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-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",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@scalar/api-reference-react": "^0.7.42",
"@tanstack/react-query": "5.81.5",
"@thirdweb-dev/ai-sdk-provider": "workspace:*",
"@workspace/ui": "workspace:*",
"ai": "^5.0.25",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"geist": "^1.5.1",
"lucide-react": "0.525.0",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"openapi-types": "12.1.3",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-dom": "19.2.1",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-pick-color": "^2.0.0",
"remark-gfm": "4.0.1",
"server-only": "^0.0.1",
"shiki": "3.12.0",
"sonner": "2.0.6",
"streamdown": "^1.1.4",
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
"use-stick-to-bottom": "^1.1.1",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@types/node": "22.14.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-config-next": "15.3.6",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"knip": "5.60.2",
"postcss": "8.5.6",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.8.3"
},
"name": "playground-web",
"private": true,
"scripts": {
"build": "next build",
"dev": "rm -rf .next && next dev --turbopack",
"fix": "eslint ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"prefix": "biome check ./src --fix",
"prelint": "biome check ./src",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"version": "0.1.1"
}
+7 -7
View File
@@ -3,7 +3,7 @@
"@dirtycajunrice/klee": "^1.0.6",
"@mdx-js/loader": "3.1.0",
"@mdx-js/react": "3.1.0",
"@next/mdx": "15.3.5",
"@next/mdx": "15.3.6",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-select": "^2.2.5",
@@ -21,7 +21,7 @@
"github-slugger": "^2.0.0",
"he": "^1.2.0",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next": "15.3.6",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"node-html-markdown": "^1.3.0",
@@ -29,9 +29,9 @@
"openapi-types": "12.1.3",
"posthog-js": "1.256.1",
"prettier": "3.6.2",
"react": "19.1.0",
"react": "19.2.1",
"react-children-utilities": "^2.10.0",
"react-dom": "19.1.0",
"react-dom": "19.2.1",
"react-html-parser": "2.0.2",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
@@ -46,13 +46,13 @@
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@next/eslint-plugin-next": "15.3.5",
"@next/eslint-plugin-next": "15.3.6",
"@types/flexsearch": "^0.7.42",
"@types/he": "^1.2.3",
"@types/mdx": "^2.0.13",
"@types/node": "22.14.1",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-html-parser": "^2.0.7",
"@types/tryghost__content-api": "^1.3.16",
"@typescript-eslint/eslint-plugin": "7.14.1",
+1 -1
View File
@@ -71,7 +71,7 @@ export const sidebar: SideBar = {
isCollapsible: false,
links: [
{
href: "/reference#tag/contracts",
href: "/reference#tag/gateway",
icon: <CodeIcon />,
name: "HTTP API",
},
+1 -1
View File
@@ -35,7 +35,7 @@ Server wallets are wallets that are managed by your own application, like a trea
### Use an existing Server Wallet
Once created, you can use your server wallet by passing it as the `from` field of the [thirdweb API](/reference#tag/transactions/post/v1/transactions).
Once created, you can use your server wallet by passing it as the `from` field of the [thirdweb API](/reference#tag/wallets/post/v1/transactions).
### Create a new Server Wallet
@@ -64,7 +64,7 @@ Send, monitor, and manage transactions. Send transactions from user or server wa
### Send raw transactions
Send a raw transaction from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/transactions/post/v1/transactions).
Send a raw transaction from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/wallets/post/v1/transactions).
- For server wallets, you can execute transactions with just your project secret key.
- For user wallets in React applications that use the SDK, you can obtain the user wallet auth token (JWT) with the [`useAuthToken()`](/references/typescript/v5/useAuthToken) hook.
@@ -74,7 +74,7 @@ Send, monitor, and manage transactions. Send transactions from user or server wa
### Send contract calls
Send a transaction to a contract from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/contracts/post/v1/contracts/{chainId}/{address}/write).
Send a transaction to a contract from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/wallets/post/v1/contracts/{chainId}/{address}/write).
- For server wallets, you can execute transactions with just your project secret key.
- For user wallets in React applications that use the SDK, you can obtain the user wallet auth token (JWT) with the [`useAuthToken()`](/references/typescript/v5/useAuthToken) hook.
+4
View File
@@ -82,6 +82,10 @@ export const sidebar: SideBar = {
href: `${walletSlug}/monitor`,
name: "Monitor Transactions",
},
{
href: `${walletSlug}/solana`,
name: "Solana Transactions",
},
],
},
{ separator: true },
+237
View File
@@ -0,0 +1,237 @@
import {
Callout,
createMetadata,
OpenApiEndpoint,
Steps,
Step,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@doc";
export const metadata = createMetadata({
image: {
title: "Solana Server Wallets",
icon: "wallets",
},
title: "Solana Server Wallets | thirdweb",
description:
"Create and manage Solana server wallets for backend applications",
});
# Solana Server Wallets
Create and manage Solana wallets for backend applications using thirdweb's secure server wallet infrastructure.
<Callout variant="info" title="Funding Required">
Solana server wallets are **not sponsored**. You must fund your wallet with SOL to transact.
</Callout>
## Capabilities
- **Wallet Management**: Create and list Solana wallets
- **Balance & Transactions**: Check balances, send SOL/SPL tokens, query transaction status
- **Signing**: Sign messages and transactions without broadcasting
- **Token Swaps**: Get quotes and execute swaps (mainnet only)
- **Raw Transactions**: Build and broadcast custom transactions with full instruction control
## Getting Started
<Steps>
<Step title="Create a Server Wallet">
Create a Solana server wallet from the [dashboard](https://thirdweb.com/team/~/~/settings/server-wallets) or via API.
</Step>
<Step title="Fund Your Wallet">
Transfer SOL to your wallet address to cover transaction fees.
</Step>
<Step title="Start Transacting">
Send tokens, sign messages, execute swaps, or submit custom transactions.
</Step>
</Steps>
---
## Wallet Management
### List Solana Wallets
<OpenApiEndpoint path="/v1/solana/wallets" method="GET" />
### Create Solana Wallet
<OpenApiEndpoint path="/v1/solana/wallets" method="POST" />
---
## Balance & Tokens
### Get Wallet Balance
<OpenApiEndpoint path="/v1/solana/wallets/{address}/balance" method="GET" />
### Send Tokens
Transfer native SOL or SPL tokens. Automatically handles token account creation for SPL tokens.
<OpenApiEndpoint path="/v1/solana/send" method="POST" />
---
## Message & Transaction Signing
### Sign Message
<OpenApiEndpoint path="/v1/solana/sign-message" method="POST" />
### Sign Transaction
Sign a transaction without broadcasting. Provide a serialized transaction or instructions to assemble one.
<OpenApiEndpoint path="/v1/solana/sign-transaction" method="POST" />
### Broadcast Signed Transaction
<OpenApiEndpoint path="/v1/solana/broadcast-transaction" method="POST" />
---
## Custom Transactions
### Send Transaction
Submit a transaction composed of one or more instructions. Transactions are queued and processed asynchronously.
<OpenApiEndpoint path="/v1/solana/transactions" method="POST" />
### Get Transaction Status
<OpenApiEndpoint path="/v1/solana/transactions/{transactionId}" method="GET" />
---
## Token Swaps
<Callout variant="info" title="Mainnet Only">
Token swaps are only available on Solana mainnet.
</Callout>
### Get Swap Quote
<OpenApiEndpoint path="/v1/solana/swap" method="GET" />
### Execute Swap
<OpenApiEndpoint path="/v1/solana/swap" method="POST" />
---
## Example Workflow
<Tabs defaultValue="create">
<TabsList>
<TabsTrigger value="create">1. Create Wallet</TabsTrigger>
<TabsTrigger value="fund">2. Check Balance</TabsTrigger>
<TabsTrigger value="send">3. Send Tokens</TabsTrigger>
<TabsTrigger value="swap">4. Swap Tokens</TabsTrigger>
</TabsList>
<TabsContent value="create">
```bash
curl -X POST "https://api.thirdweb.com/v1/solana/wallets" \
-H "Content-Type: application/json" \
-H "x-secret-key: YOUR_SECRET_KEY" \
-d '{"label": "treasury-wallet"}'
```
```json
{
"result": {
"address": "8JBLmveV4YF5AQ7EVnKJgyj6etGgVkPp3tYqQMTu3p5B",
"label": "treasury-wallet",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
}
```
</TabsContent>
<TabsContent value="fund">
```bash
curl -X GET "https://api.thirdweb.com/v1/solana/wallets/8JBLmveV4YF5AQ7EVnKJgyj6etGgVkPp3tYqQMTu3p5B/balance?chainId=solana:mainnet" \
-H "x-secret-key: YOUR_SECRET_KEY"
```
```json
{
"result": {
"chainId": "solana:mainnet",
"decimals": 9,
"displayValue": "1.5",
"value": "1500000000"
}
}
```
</TabsContent>
<TabsContent value="send">
```bash
curl -X POST "https://api.thirdweb.com/v1/solana/send" \
-H "Content-Type: application/json" \
-H "x-secret-key: YOUR_SECRET_KEY" \
-d '{
"from": "8JBLmveV4YF5AQ7EVnKJgyj6etGgVkPp3tYqQMTu3p5B",
"to": "FhtwVYF1wKAm7fWmE2N5P2eCv13wt2aT8W4Q9NQ9YcJH",
"amount": "100000000",
"chainId": "solana:mainnet"
}'
```
```json
{
"result": {
"transactionId": "tx_abc123..."
}
}
```
</TabsContent>
<TabsContent value="swap">
```bash
curl -X POST "https://api.thirdweb.com/v1/solana/swap" \
-H "Content-Type: application/json" \
-H "x-secret-key: YOUR_SECRET_KEY" \
-d '{
"address": "8JBLmveV4YF5AQ7EVnKJgyj6etGgVkPp3tYqQMTu3p5B",
"tokenIn": "So11111111111111111111111111111111111111112",
"tokenOut": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"amount": "100000000",
"chainId": "solana:mainnet"
}'
```
```json
{
"result": {
"signature": "5ttCNobho7nk5F...",
"inputMint": "So11111111111111111111111111111111111111112",
"outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"inputAmount": "100000000",
"outputAmount": "95423156",
"requestId": "uuid-1234-5678"
}
}
```
</TabsContent>
</Tabs>
---
## Network Identifiers
| Network | Identifier |
|---------|------------|
| Mainnet | `solana:mainnet` |
| Devnet | `solana:devnet` |
@@ -0,0 +1,173 @@
import { Callout, createMetadata } from "@doc";
export const metadata = createMetadata({
image: {
title: "Solana Facilitator",
icon: "solana",
},
title: "Solana Facilitator",
description: "Verify and settle Solana transactions through your own facilitator wallets.",
});
# Solana Facilitator
Solana is now a supported network for x402 payments. You can gate, verify, and settle Solana transactions through your own facilitator wallets — fully end-to-end.
<Callout variant="info" title="Supported Networks">
We support Solana Mainnet and Devnet. Use the following network identifiers:
- **Mainnet**: `solana:mainnet`
- **Devnet**: `solana:devnet`
</Callout>
This enables you to spin up your own server wallet, prepare payment payloads, and handle verification + settlement directly on Solana (devnet or mainnet). No middle layers. No bloat.
## Features
- **Solana facilitator flow** — create or register a Solana server wallet, quote pricing with `/v1/payments/x402/accepts`, and settle signed transactions with `/v1/payments/x402/settle`.
- **Unified verify path** — `/v1/payments/x402/verify` now validates Solana payloads the same way it does for EVM, so you can reuse your middleware checks across chains.
- **End-to-end examples** — refreshed snippets for Node environments that show the exact headers, payloads, and success envelopes you should expect in production.
## Fast Start Guide
### 1. Create a facilitator wallet
First, create a server-side wallet that will act as the facilitator.
```ts
const response = await fetch("https://api.thirdweb.com/v1/solana/wallets", {
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "my-solana-facilitator",
}),
});
const wallet = await response.json();
console.log(wallet.result.address);
```
The returned address is the server-side payer for settles, so double-check that it appears in your project’s server wallet list.
<Callout variant="info">
**Fund the wallet:** Top up SOL for fees on the target cluster (devnet or mainnet-beta).
</Callout>
### 2. Quote access
Use `/v1/payments/x402/accepts` to generate the payment requirements.
```ts
const acceptsResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/accepts",
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
resourceUrl: "https://example.com/solana-protected",
method: "POST",
network: "solana:devnet",
price: {
amount: "0",
asset: {
address: "So11111111111111111111111111111111111111112",
decimals: 9,
tokenProgram: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
},
},
serverWalletAddress: "YOUR_SOLANA_FACILITATOR_ADDRESS",
}),
}
);
const { accepts } = await acceptsResponse.json();
const paymentRequirements = accepts[0];
```
Keep `paymentRequirements` around—clients will sign against it, and the facilitator will reuse it when settling.
### 3. Verify before you settle
Once you have the signed payment payload from the client, you can verify it.
```ts
const verifyResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/verify",
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentPayload: SIGNED_PAYMENT_PAYLOAD,
paymentRequirements,
}),
}
);
const verifyData = await verifyResponse.json();
console.log(verifyData.isValid);
```
A truthy `isValid` means the signatures, blockhash, and amounts all check out.
### 4. Settle on-chain
Settle the transaction using your facilitator wallet.
```ts
const settleResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/settle",
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentPayload: SIGNED_PAYMENT_PAYLOAD,
paymentRequirements,
waitUntil: "submitted",
}),
}
);
const settleData = await settleResponse.json();
console.log(settleData.transaction);
```
<Callout variant="info">
Use `waitUntil: "confirmed"` if you need finality before responding to the client.
</Callout>
Watch your server wallet dashboard or any Solana explorer for the transaction signature the settle endpoint returns.
### Fetch protected resources
Let payers fetch protected resources. If the client hits the protected endpoint first, have it replay the request through `/v1/payments/x402/fetch`. The API will answer with the payload you need for verification and settlement.
```ts
const fetchResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/fetch?" +
new URLSearchParams({
url: "https://example.com/solana-protected",
from: "PAYER_SOLANA_ADDRESS",
chainId: "solana:devnet",
}),
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
},
}
);
const fetchData = await fetchResponse.json();
```
+4
View File
@@ -37,6 +37,10 @@ export const sidebar: SideBar = {
href: `${x402Slug}/facilitator`,
name: "Facilitator",
},
{
href: `${x402Slug}/facilitator/solana`,
name: "Solana",
},
],
name: "Guides",
},
+24
View File
@@ -1,5 +1,29 @@
# wagmi-inapp
## 0.0.42
### Patch Changes
- Updated dependencies [[`7b873d3`](https://github.com/thirdweb-dev/js/commit/7b873d38db373151ef42dfa7bc1e083568cb5432), [`b0eccf3`](https://github.com/thirdweb-dev/js/commit/b0eccf365a7521ec882fca9e680837c84147041e), [`a704c3f`](https://github.com/thirdweb-dev/js/commit/a704c3f2b00434f8f50653aa2825b4adf7cd4696)]:
- thirdweb@5.115.3
- @thirdweb-dev/wagmi-adapter@0.2.197
## 0.0.41
### Patch Changes
- Updated dependencies [[`859032d`](https://github.com/thirdweb-dev/js/commit/859032dc1761afdb7a4e12b40534e3a98b771498)]:
- thirdweb@5.115.2
- @thirdweb-dev/wagmi-adapter@0.2.196
## 0.0.40
### Patch Changes
- Updated dependencies [[`551ec68`](https://github.com/thirdweb-dev/js/commit/551ec680df3a84d2cacca4dfb9f0522fd7383f7a), [`b6be8d6`](https://github.com/thirdweb-dev/js/commit/b6be8d67ef74f425e911d900a9adb19f9a70efd9)]:
- thirdweb@5.115.1
- @thirdweb-dev/wagmi-adapter@0.2.195
## 0.0.39
### Patch Changes
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "wagmi-inapp",
"private": true,
"version": "0.0.39",
"version": "0.0.42",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,16 +14,16 @@
"dependencies": {
"@tanstack/react-query": "5.81.5",
"@thirdweb-dev/wagmi-adapter": "workspace:*",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"thirdweb": "workspace:*",
"viem": "2.39.0",
"wagmi": "2.19.4"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.0.4",
"@wagmi/cli": "latest",
"buffer": "^6.0.3",
+6 -6
View File
@@ -10,10 +10,10 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next": "15.3.6",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-hook-form": "7.55.0",
"server-only": "^0.0.1",
"sonner": "2.0.6",
@@ -25,10 +25,10 @@
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@next/eslint-plugin-next": "15.3.5",
"@next/eslint-plugin-next": "15.3.6",
"@types/node": "22.14.1",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"eslint": "8.57.0",
+21
View File
@@ -1,5 +1,26 @@
# @thirdweb-dev/nebula
## 0.2.100
### Patch Changes
- Updated dependencies [[`7b873d3`](https://github.com/thirdweb-dev/js/commit/7b873d38db373151ef42dfa7bc1e083568cb5432), [`b0eccf3`](https://github.com/thirdweb-dev/js/commit/b0eccf365a7521ec882fca9e680837c84147041e), [`a704c3f`](https://github.com/thirdweb-dev/js/commit/a704c3f2b00434f8f50653aa2825b4adf7cd4696)]:
- thirdweb@5.115.3
## 0.2.99
### Patch Changes
- Updated dependencies [[`859032d`](https://github.com/thirdweb-dev/js/commit/859032dc1761afdb7a4e12b40534e3a98b771498)]:
- thirdweb@5.115.2
## 0.2.98
### Patch Changes
- Updated dependencies [[`551ec68`](https://github.com/thirdweb-dev/js/commit/551ec680df3a84d2cacca4dfb9f0522fd7383f7a), [`b6be8d6`](https://github.com/thirdweb-dev/js/commit/b6be8d67ef74f425e911d900a9adb19f9a70efd9)]:
- thirdweb@5.115.1
## 0.2.97
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.97"
"version": "0.2.100"
}
+24
View File
@@ -1,5 +1,29 @@
# thirdweb
## 5.115.3
### Patch Changes
- [#8509](https://github.com/thirdweb-dev/js/pull/8509) [`7b873d3`](https://github.com/thirdweb-dev/js/commit/7b873d38db373151ef42dfa7bc1e083568cb5432) Thanks [@MananTank](https://github.com/MananTank)! - Update success message shown in SwapWidget component
- [#8501](https://github.com/thirdweb-dev/js/pull/8501) [`b0eccf3`](https://github.com/thirdweb-dev/js/commit/b0eccf365a7521ec882fca9e680837c84147041e) Thanks [@MananTank](https://github.com/MananTank)! - Remove tabs from token selection UI in SwapWidget
- [#8504](https://github.com/thirdweb-dev/js/pull/8504) [`a704c3f`](https://github.com/thirdweb-dev/js/commit/a704c3f2b00434f8f50653aa2825b4adf7cd4696) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Allow passing an activeWallet to SwapWidget
## 5.115.2
### Patch Changes
- [#8496](https://github.com/thirdweb-dev/js/pull/8496) [`859032d`](https://github.com/thirdweb-dev/js/commit/859032dc1761afdb7a4e12b40534e3a98b771498) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Skip swap review step when coming from swap widget
## 5.115.1
### Patch Changes
- [#8490](https://github.com/thirdweb-dev/js/pull/8490) [`551ec68`](https://github.com/thirdweb-dev/js/commit/551ec680df3a84d2cacca4dfb9f0522fd7383f7a) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Payment selection tracking
- [#8495](https://github.com/thirdweb-dev/js/pull/8495) [`b6be8d6`](https://github.com/thirdweb-dev/js/commit/b6be8d67ef74f425e911d900a9adb19f9a70efd9) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Faster token balance load query
## 5.115.0
### Minor Changes
+5 -5
View File
@@ -64,8 +64,8 @@
"@types/cross-spawn": "^6.0.6",
"@types/prompts": "2.4.9",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@viem/anvil": "0.0.10",
"@vitejs/plugin-react": "5.0.4",
"@vitest/coverage-v8": "3.2.4",
@@ -79,8 +79,8 @@
"knip": "5.60.2",
"msw": "2.7.5",
"prettier": "3.6.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-native": "0.78.1",
"react-native-aes-gcm-crypto": "0.2.2",
"react-native-passkey": "3.1.0",
@@ -430,5 +430,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.115.0"
"version": "5.115.3"
}
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import type { Quote } from "../../../bridge/index.js";
import { ApiError } from "../../../bridge/types/Errors.js";
import type { Token, TokenWithPrices } from "../../../bridge/types/Token.js";
import type { TokenWithPrices } from "../../../bridge/types/Token.js";
import type { ThirdwebClient } from "../../../client/client.js";
import { getThirdwebBaseUrl } from "../../../utils/domains.js";
import { getClientFetch } from "../../../utils/fetch.js";
@@ -29,7 +29,7 @@ import { useActiveWallet } from "./wallets/useActiveWallet.js";
* ```
*/
export function usePaymentMethods(options: {
destinationToken: Token;
destinationToken: TokenWithPrices;
destinationAmount: string;
client: ThirdwebClient;
payerWallet?: Wallet;
@@ -65,6 +65,8 @@ export function usePaymentMethods(options: {
"amount",
toUnits(destinationAmount, destinationToken.decimals).toString(),
);
// dont include quotes to speed up the query
url.searchParams.set("includeQuotes", "false");
const clientFetch = getClientFetch(client);
const response = await clientFetch(url.toString());
@@ -80,8 +82,9 @@ export function usePaymentMethods(options: {
const {
data: allValidOriginTokens,
}: { data: { quote: Quote; balance: string; token: TokenWithPrices }[] } =
await response.json();
}: {
data: { quote?: Quote; balance: string; token: TokenWithPrices }[];
} = await response.json();
// Sort by enough balance to pay THEN gross balance
const validTokenQuotes = allValidOriginTokens.map((s) => ({
@@ -92,7 +95,7 @@ export function usePaymentMethods(options: {
quote: s.quote,
}));
const sufficientBalanceQuotes = validTokenQuotes
const sortedValidTokenQuotes = validTokenQuotes
.filter((s) => !!s.originToken.prices.USD)
.sort((a, b) => {
return (
@@ -114,18 +117,29 @@ export function usePaymentMethods(options: {
)
: [];
const finalQuotes = supportedTokens
? sufficientBalanceQuotes.filter((q) =>
? sortedValidTokenQuotes.filter((q) =>
tokensToInclude.find(
(t) =>
t.chainId === q.originToken.chainId &&
t.address.toLowerCase() === q.originToken.address.toLowerCase(),
),
)
: sufficientBalanceQuotes;
return finalQuotes.map((x) => ({
...x,
action: "buy",
}));
: sortedValidTokenQuotes;
const requiredUsdValue =
(destinationToken.prices?.["USD"] ?? 0) * Number(destinationAmount);
return finalQuotes.map((x) => {
const tokenUsdValue =
(x.originToken.prices?.["USD"] ?? 0) *
Number(toTokens(x.balance, x.originToken.decimals));
const hasEnoughBalance = tokenUsdValue >= requiredUsdValue;
return {
...x,
action: "buy",
hasEnoughBalance,
};
});
},
queryKey: [
"payment-methods",
@@ -1,7 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import type { TokenWithPrices } from "../../../../bridge/index.js";
import type { Chain } from "../../../../chains/types.js";
@@ -327,18 +326,17 @@ export type BuyWidgetProps = {
* @bridge
*/
export function BuyWidget(props: BuyWidgetProps) {
useQuery({
queryFn: () => {
trackPayEvent({
client: props.client,
event: "ub:ui:buy_widget:render",
toChainId: props.chain?.id,
toToken: props.tokenAddress,
});
return true;
},
queryKey: ["buy_widget:render"],
});
const hasFiredRenderEvent = useRef(false);
useEffect(() => {
if (hasFiredRenderEvent.current) return;
hasFiredRenderEvent.current = true;
trackPayEvent({
client: props.client,
event: "ub:ui:buy_widget:render",
toChainId: props.chain?.id,
toToken: props.tokenAddress,
});
}, [props.client, props.chain?.id, props.tokenAddress]);
// if branding is disabled for widget, disable it for connect options too
const connectOptions = useMemo(() => {
@@ -500,6 +498,12 @@ function BridgeWidgetContent(
client={props.client}
connectOptions={props.connectOptions}
onContinue={(destinationAmount, destinationToken, receiverAddress) => {
trackPayEvent({
client: props.client,
event: "payment_selection",
toChainId: destinationToken.chainId,
toToken: destinationToken.address,
});
setScreen({
id: "2:methodSelection",
destinationAmount,
@@ -548,6 +552,20 @@ function BridgeWidgetContent(
handleError(error, undefined);
}}
onPaymentMethodSelected={(paymentMethod) => {
trackPayEvent({
chainId:
paymentMethod.type === "wallet"
? paymentMethod.originToken.chainId
: undefined,
client: props.client,
event: "ub:ui:loading_quote:fund_wallet",
fromToken:
paymentMethod.type === "wallet"
? paymentMethod.originToken.address
: undefined,
toChainId: screen.destinationToken.chainId,
toToken: screen.destinationToken.address,
});
setScreen({
...screen,
id: "3:load-quote",
@@ -581,6 +599,37 @@ function BridgeWidgetContent(
handleError(error, undefined);
}}
onQuoteReceived={(preparedQuote, request) => {
trackPayEvent({
chainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.type === "onramp"
? preparedQuote.intent.chainId
: preparedQuote.intent.originChainId,
client: props.client,
event: "payment_details",
fromToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.type === "onramp"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.originTokenAddress,
toChainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.type === "onramp"
? preparedQuote.intent.chainId
: preparedQuote.intent.destinationChainId,
toToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.type === "onramp"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.destinationTokenAddress,
walletAddress:
screen.paymentMethod.payerWallet?.getAccount()?.address,
walletType: screen.paymentMethod.payerWallet?.id,
});
setScreen({
...screen,
id: "4:preview",
@@ -674,6 +723,7 @@ function BridgeWidgetContent(
if (screen.id === "6:success") {
return (
<SuccessScreen
type="payment-success"
// from props
client={props.client}
hasPaymentId={!!props.paymentLinkId}
@@ -1,7 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import type { TokenWithPrices } from "../../../../bridge/index.js";
import type { Chain } from "../../../../chains/types.js";
@@ -335,18 +334,17 @@ function CheckoutWidgetContentWrapper(props: CheckoutWidgetProps) {
client: props.client,
});
useQuery({
queryFn: () => {
trackPayEvent({
client: props.client,
event: "ub:ui:checkout_widget:render",
toChainId: props.chain.id,
toToken: props.tokenAddress,
});
return true;
},
queryKey: ["checkout_widget:render"],
});
const hasFiredRenderEvent = useRef(false);
useEffect(() => {
if (hasFiredRenderEvent.current) return;
hasFiredRenderEvent.current = true;
trackPayEvent({
client: props.client,
event: "ub:ui:checkout_widget:render",
toChainId: props.chain.id,
toToken: props.tokenAddress,
});
}, [props.client, props.chain.id, props.tokenAddress]);
// if branding is disabled for widget, disable it for connect options too
const connectOptions = useMemo(() => {
@@ -522,6 +520,12 @@ function CheckoutWidgetContent(
buttonLabel={props.buttonLabel}
// others
onContinue={(destinationAmount, destinationToken, receiverAddress) => {
trackPayEvent({
client: props.client,
event: "payment_selection",
toChainId: destinationToken.chainId,
toToken: destinationToken.address,
});
setScreen({
id: "2:methodSelection",
destinationAmount,
@@ -556,6 +560,20 @@ function CheckoutWidgetContent(
handleError(error, undefined);
}}
onPaymentMethodSelected={(paymentMethod) => {
trackPayEvent({
chainId:
paymentMethod.type === "wallet"
? paymentMethod.originToken.chainId
: undefined,
client: props.client,
event: "ub:ui:loading_quote:direct_payment",
fromToken:
paymentMethod.type === "wallet"
? paymentMethod.originToken.address
: undefined,
toChainId: screen.destinationToken.chainId,
toToken: screen.destinationToken.address,
});
setScreen({
...screen,
id: "3:load-quote",
@@ -589,6 +607,35 @@ function CheckoutWidgetContent(
handleError(error, undefined);
}}
onQuoteReceived={(preparedQuote, request) => {
if (
preparedQuote.type === "buy" ||
preparedQuote.type === "sell" ||
preparedQuote.type === "transfer"
) {
trackPayEvent({
chainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.intent.originChainId,
client: props.client,
event: "payment_details",
fromToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.originTokenAddress,
toChainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.intent.destinationChainId,
toToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.destinationTokenAddress,
walletAddress:
screen.paymentMethod.payerWallet?.getAccount()?.address,
walletType: screen.paymentMethod.payerWallet?.id,
});
}
setScreen({
...screen,
id: "4:preview",
@@ -683,6 +730,7 @@ function CheckoutWidgetContent(
if (screen.id === "6:success") {
return (
<SuccessScreen
type="payment-success"
// from props
client={props.client}
hasPaymentId={!!props.paymentLinkId}
@@ -1,6 +1,6 @@
"use client";
import { CrossCircledIcon } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js";
@@ -38,17 +38,16 @@ export function ErrorBanner({
const { userMessage } = useBridgeError({ error });
useQuery({
queryFn: () => {
trackPayEvent({
client,
error: error.message,
event: "ub:ui:error",
});
return true;
},
queryKey: ["error_banner", userMessage],
});
const hasFiredErrorEvent = useRef(false);
useEffect(() => {
if (hasFiredErrorEvent.current) return;
hasFiredErrorEvent.current = true;
trackPayEvent({
client,
error: error.message,
event: "ub:ui:error",
});
}, [client, error.message]);
return (
<Container flex="column" fullHeight gap="md" p="md">
@@ -1,7 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import type { Token } from "../../../../bridge/types/Token.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import type { PurchaseData } from "../../../../pay/types.js";
@@ -92,7 +90,7 @@ export function QuoteLoader({
purchaseData,
paymentLinkId,
feePayer,
mode,
mode: _mode,
}: QuoteLoaderProps) {
const request: BridgePrepareRequest = getBridgeParams({
amount,
@@ -107,28 +105,6 @@ export function QuoteLoader({
});
const prepareQuery = useBridgePrepare(request);
useQuery({
queryFn: () => {
trackPayEvent({
chainId:
paymentMethod.type === "wallet"
? paymentMethod.originToken.chainId
: undefined,
client,
event: `ub:ui:loading_quote:${mode}`,
fromToken:
paymentMethod.type === "wallet"
? paymentMethod.originToken.address
: undefined,
toChainId: destinationToken.chainId,
toToken: destinationToken.address,
walletAddress: sender,
});
return true;
},
queryKey: ["loading_quote", paymentMethod.type],
});
// Handle successful quote
useEffect(() => {
if (prepareQuery.data) {
@@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import type { TokenWithPrices } from "../../../../bridge/index.js";
import type { Chain } from "../../../../chains/types.js";
@@ -349,18 +349,17 @@ type TransactionQueryResult =
};
export function TransactionWidgetContentWrapper(props: TransactionWidgetProps) {
useQuery({
queryFn: () => {
trackPayEvent({
chainId: props.transaction.chain.id,
client: props.client,
event: "ub:ui:transaction_widget:render",
toToken: props.tokenAddress,
});
return true;
},
queryKey: ["transaction_widget:render"],
});
const hasFiredRenderEvent = useRef(false);
useEffect(() => {
if (hasFiredRenderEvent.current) return;
hasFiredRenderEvent.current = true;
trackPayEvent({
chainId: props.transaction.chain.id,
client: props.client,
event: "ub:ui:transaction_widget:render",
toToken: props.tokenAddress,
});
}, [props.client, props.transaction.chain.id, props.tokenAddress]);
const localQuery = useConnectLocale(props.locale || "en_US");
@@ -573,6 +572,12 @@ function TransactionWidgetContent(
}}
connectOptions={props.connectOptions}
onContinue={(destinationAmount, destinationToken, receiverAddress) => {
trackPayEvent({
client: props.client,
event: "payment_selection",
toChainId: destinationToken.chainId,
toToken: destinationToken.address,
});
setScreen({
id: "buy:1.methodSelection",
destinationAmount,
@@ -618,6 +623,20 @@ function TransactionWidgetContent(
handleError(error);
}}
onPaymentMethodSelected={(paymentMethod) => {
trackPayEvent({
chainId:
paymentMethod.type === "wallet"
? paymentMethod.originToken.chainId
: undefined,
client: props.client,
event: "ub:ui:loading_quote:transaction",
fromToken:
paymentMethod.type === "wallet"
? paymentMethod.originToken.address
: undefined,
toChainId: screen.destinationToken.chainId,
toToken: screen.destinationToken.address,
});
setScreen({
...screen,
id: "buy:2.load-quote",
@@ -651,6 +670,35 @@ function TransactionWidgetContent(
handleError(error);
}}
onQuoteReceived={(preparedQuote, request) => {
if (
preparedQuote.type === "buy" ||
preparedQuote.type === "sell" ||
preparedQuote.type === "transfer"
) {
trackPayEvent({
chainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.intent.originChainId,
client: props.client,
event: "payment_details",
fromToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.originTokenAddress,
toChainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.intent.destinationChainId,
toToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.destinationTokenAddress,
walletAddress:
screen.paymentMethod.payerWallet?.getAccount()?.address,
walletType: screen.paymentMethod.payerWallet?.id,
});
}
setScreen({
...screen,
id: "buy:3.preview",
@@ -738,6 +786,7 @@ function TransactionWidgetContent(
<SuccessScreen
// from props
client={props.client}
type="payment-success"
hasPaymentId={!!props.paymentLinkId}
// others
completedStatuses={screen.completedStatuses}
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import type { Chain } from "../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../client/client.js";
@@ -27,17 +27,17 @@ export function UnsupportedTokenScreen(props: UnsupportedTokenScreenProps) {
const { data: chainMetadata } = useChainMetadata(chain);
useQuery({
queryFn: () => {
trackPayEvent({
chainId: chain.id,
client,
event: "ub:ui:unsupported_token",
fromToken: tokenAddress,
});
},
queryKey: ["unsupported_token"],
});
const hasFiredUnsupportedEvent = useRef(false);
useEffect(() => {
if (hasFiredUnsupportedEvent.current) return;
hasFiredUnsupportedEvent.current = true;
trackPayEvent({
chainId: chain.id,
client,
event: "ub:ui:unsupported_token",
fromToken: tokenAddress,
});
}, [chain.id, client, tokenAddress]);
if (chainMetadata?.testnet) {
return (
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import { defineChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
@@ -251,16 +250,15 @@ export type BridgeWidgetProps = {
export function BridgeWidget(props: BridgeWidgetProps) {
const [tab, setTab] = useState<"swap" | "buy">("swap");
useQuery({
queryFn: () => {
trackPayEvent({
client: props.client,
event: "ub:ui:bridge_widget:render",
});
return true;
},
queryKey: ["bridge_widget:render"],
});
const hasFiredRenderEvent = useRef(false);
useEffect(() => {
if (hasFiredRenderEvent.current) return;
hasFiredRenderEvent.current = true;
trackPayEvent({
client: props.client,
event: "ub:ui:bridge_widget:render",
});
}, [props.client]);
return (
<CustomThemeProvider theme={props.theme}>
@@ -1,7 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import { defineChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
@@ -82,41 +80,6 @@ export function PaymentDetails({
}
};
useQuery({
queryFn: () => {
if (
preparedQuote.type === "buy" ||
preparedQuote.type === "sell" ||
preparedQuote.type === "transfer"
) {
trackPayEvent({
chainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.intent.originChainId,
client,
event: "payment_details",
fromToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.originTokenAddress,
toChainId:
preparedQuote.type === "transfer"
? preparedQuote.intent.chainId
: preparedQuote.intent.destinationChainId,
toToken:
preparedQuote.type === "transfer"
? preparedQuote.intent.tokenAddress
: preparedQuote.intent.destinationTokenAddress,
walletAddress: paymentMethod.payerWallet?.getAccount()?.address,
walletType: paymentMethod.payerWallet?.id,
});
}
return true;
},
queryKey: ["payment_details", preparedQuote.type],
});
const chainsQuery = useChainsQuery(
preparedQuote.steps.flatMap((s) => [
defineChain(s.originToken.chainId),
@@ -1,8 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import type { Token } from "../../../../../bridge/types/Token.js";
import type { TokenWithPrices } from "../../../../../bridge/types/Token.js";
import { defineChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
@@ -27,7 +26,7 @@ type PaymentSelectionProps = {
/**
* The destination token to bridge to
*/
destinationToken: Token;
destinationToken: TokenWithPrices;
/**
* The destination amount to bridge
@@ -129,21 +128,6 @@ export function PaymentSelection({
const [currentStep, setCurrentStep] = useState<Step>(initialStep);
useQuery({
queryFn: () => {
trackPayEvent({
client,
event: "payment_selection",
toChainId: destinationToken.chainId,
toToken: destinationToken.address,
walletAddress: payerWallet?.getAccount()?.address,
walletType: payerWallet?.id,
});
return true;
},
queryKey: ["payment_selection"],
});
const payerWallet =
currentStep.type === "tokenSelection"
? currentStep.selectedWallet
@@ -176,6 +160,10 @@ export function PaymentSelection({
};
const handleWalletSelected = (wallet: Wallet) => {
trackPayEvent({
client,
event: "ub:ui:token_selection",
});
setCurrentStep({ selectedWallet: wallet, type: "tokenSelection" });
};
@@ -184,6 +172,10 @@ export function PaymentSelection({
};
const handleFiatSelected = () => {
trackPayEvent({
client,
event: "ub:ui:fiat_provider_selection",
});
setCurrentStep({ type: "fiatProviderSelection" });
};
@@ -47,10 +47,7 @@ function PaymentMethodTokenRow({
}: PaymentMethodTokenRowProps) {
const theme = useCustomTheme();
const displayOriginAmount = paymentMethod.quote.originAmount;
const hasEnoughBalance = displayOriginAmount
? paymentMethod.balance >= displayOriginAmount
: false;
const hasEnoughBalance = paymentMethod.hasEnoughBalance;
const currencyPrice = paymentMethod.originToken.prices[currency || "USD"];
return (
@@ -1,7 +1,7 @@
"use client";
import { CheckIcon } from "@radix-ui/react-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { WindowAdapter } from "../../../../core/adapters/WindowAdapter.js";
@@ -46,6 +46,8 @@ type SuccessScreenProps = {
* Whether or not this payment is associated with a payment ID. If it does, we show a different message.
*/
hasPaymentId: boolean;
type: "swap-success" | "payment-success";
};
type ViewState = "success" | "detail";
@@ -58,48 +60,49 @@ export function SuccessScreen({
client,
hasPaymentId = false,
showContinueWithTx,
type,
}: SuccessScreenProps) {
const theme = useCustomTheme();
const [viewState, setViewState] = useState<ViewState>("success");
const queryClient = useQueryClient();
useQuery({
queryFn: () => {
if (preparedQuote.type === "buy" || preparedQuote.type === "sell") {
trackPayEvent({
chainId: preparedQuote.intent.originChainId,
client: client,
event: "ub:ui:success_screen",
fromToken: preparedQuote.intent.originTokenAddress,
toChainId: preparedQuote.intent.destinationChainId,
toToken: preparedQuote.intent.destinationTokenAddress,
walletAddress: preparedQuote.intent.sender,
});
}
if (preparedQuote.type === "transfer") {
trackPayEvent({
chainId: preparedQuote.intent.chainId,
client: client,
event: "ub:ui:success_screen",
fromToken: preparedQuote.intent.tokenAddress,
toChainId: preparedQuote.intent.chainId,
toToken: preparedQuote.intent.tokenAddress,
walletAddress: preparedQuote.intent.sender,
});
}
queryClient.invalidateQueries({
queryKey: ["bridge/v1/wallets"],
const hasFiredSuccessEvent = useRef(false);
useEffect(() => {
if (hasFiredSuccessEvent.current) return;
hasFiredSuccessEvent.current = true;
if (preparedQuote.type === "buy" || preparedQuote.type === "sell") {
trackPayEvent({
chainId: preparedQuote.intent.originChainId,
client: client,
event: "ub:ui:success_screen",
fromToken: preparedQuote.intent.originTokenAddress,
toChainId: preparedQuote.intent.destinationChainId,
toToken: preparedQuote.intent.destinationTokenAddress,
walletAddress: preparedQuote.intent.sender,
});
queryClient.invalidateQueries({
queryKey: ["walletBalance"],
}
if (preparedQuote.type === "transfer") {
trackPayEvent({
chainId: preparedQuote.intent.chainId,
client: client,
event: "ub:ui:success_screen",
fromToken: preparedQuote.intent.tokenAddress,
toChainId: preparedQuote.intent.chainId,
toToken: preparedQuote.intent.tokenAddress,
walletAddress: preparedQuote.intent.sender,
});
queryClient.invalidateQueries({
queryKey: ["payment-methods"],
});
return true;
},
queryKey: ["success_screen", preparedQuote.type],
});
}
queryClient.invalidateQueries({
queryKey: ["bridge/v1/wallets"],
});
queryClient.invalidateQueries({
queryKey: ["walletBalance"],
});
queryClient.invalidateQueries({
queryKey: ["payment-methods"],
});
}, [client, preparedQuote, queryClient]);
if (viewState === "detail") {
return (
@@ -112,6 +115,13 @@ export function SuccessScreen({
);
}
const title =
type === "swap-success" ? "Swap Successful" : "Payment Successful";
const description =
type === "swap-success"
? "Your token swap has been completed successfully."
: "Your payment has been completed successfully.";
return (
<Container flex="column" fullHeight px="md" pb="md" pt="md+">
<Spacer y="3xl" />
@@ -151,7 +161,7 @@ export function SuccessScreen({
marginBottom: spacing.xxs,
}}
>
Payment Successful
{title}
</Text>
<Text center color="secondaryText" size="sm">
@@ -159,7 +169,7 @@ export function SuccessScreen({
? "You can now close this page and return to the application."
: showContinueWithTx
? "Click continue to execute your transaction."
: "Your payment has been completed successfully."}
: description}
</Text>
</div>
</Container>
@@ -173,7 +183,7 @@ export function SuccessScreen({
onClick={() => setViewState("detail")}
variant="secondary"
>
View Payment Receipt
View Transaction Receipt
</Button>
{!hasPaymentId && (
@@ -1,7 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import type { Buy, Sell } from "../../../../../bridge/index.js";
import type { TokenWithPrices } from "../../../../../bridge/types/Token.js";
@@ -9,6 +8,7 @@ import type { ThirdwebClient } from "../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { getAddress } from "../../../../../utils/address.js";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js";
import type { Theme } from "../../../../core/design-system/index.js";
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
@@ -17,7 +17,6 @@ import { webWindowAdapter } from "../../../adapters/WindowAdapter.js";
import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js";
import { DynamicHeight } from "../../components/DynamicHeight.js";
import { ErrorBanner } from "../ErrorBanner.js";
import { PaymentDetails } from "../payment-details/PaymentDetails.js";
import { SuccessScreen } from "../payment-success/SuccessScreen.js";
import { StepRunner } from "../StepRunner.js";
import { useActiveWalletInfo } from "./hooks.js";
@@ -171,6 +170,10 @@ export type SwapWidgetProps = {
* Called when the user disconnects the active wallet
*/
onDisconnect?: () => void;
/**
* The wallet that should be pre-selected in the SwapWidget UI.
*/
activeWallet?: Wallet;
};
/**
@@ -246,16 +249,15 @@ export type SwapWidgetProps = {
* @bridge
*/
export function SwapWidget(props: SwapWidgetProps) {
useQuery({
queryFn: () => {
trackPayEvent({
client: props.client,
event: "ub:ui:swap_widget:render",
});
return true;
},
queryKey: ["swap_widget:render"],
});
const hasFiredRenderEvent = useRef(false);
useEffect(() => {
if (hasFiredRenderEvent.current) return;
hasFiredRenderEvent.current = true;
trackPayEvent({
client: props.client,
event: "ub:ui:swap_widget:render",
});
}, [props.client]);
return (
<SwapWidgetContainer
@@ -323,7 +325,7 @@ function SwapWidgetContent(
},
) {
const [screen, setScreen] = useState<SwapWidgetScreen>({ id: "1:swap-ui" });
const activeWalletInfo = useActiveWalletInfo();
const activeWalletInfo = useActiveWalletInfo(props.activeWallet);
const isPersistEnabled = props.persistTokenSelections !== false;
const [amountSelection, setAmountSelection] = useState<{
@@ -366,19 +368,6 @@ function SwapWidgetContent(
// preload requests
useBridgeChains(props.client);
const handleError = useCallback(
(error: Error, quote: SwapPreparedQuote) => {
console.error(error);
props.onError?.(error, quote);
setScreen({
id: "error",
preparedQuote: quote,
error,
});
},
[props.onError],
);
// if wallet suddenly disconnects, show screen 1
if (screen.id === "1:swap-ui" || !activeWalletInfo) {
return (
@@ -402,7 +391,7 @@ function SwapWidgetContent(
setAmountSelection={setAmountSelection}
onSwap={(data) => {
setScreen({
id: "2:preview",
id: "3:execute",
buyToken: data.buyToken,
sellToken: data.sellToken,
sellTokenBalance: data.sellTokenBalance,
@@ -416,42 +405,6 @@ function SwapWidgetContent(
);
}
if (screen.id === "2:preview") {
return (
<PaymentDetails
metadata={{
title: "Review Swap",
description: undefined,
}}
confirmButtonLabel="Swap"
client={props.client}
onBack={() => {
setScreen({ id: "1:swap-ui" });
}}
onConfirm={() => {
setScreen({
...screen,
id: "3:execute",
});
}}
onError={(error) => handleError(error, screen.preparedQuote)}
paymentMethod={{
quote: screen.quote,
type: "wallet",
payerWallet: activeWalletInfo.activeWallet,
balance: screen.sellTokenBalance,
originToken: screen.sellToken,
action: screen.mode,
}}
preparedQuote={screen.preparedQuote}
currency={props.currency}
modeInfo={{
mode: "fund_wallet",
}}
/>
);
}
if (screen.id === "3:execute") {
return (
<StepRunner
@@ -462,8 +415,7 @@ function SwapWidgetContent(
onBack={() => {
setScreen({
...screen,
id: "2:preview",
sellTokenBalance: screen.sellTokenBalance,
id: "1:swap-ui",
});
}}
onCancel={() => props.onCancel?.(screen.preparedQuote)}
@@ -488,6 +440,7 @@ function SwapWidgetContent(
if (screen.id === "4:success") {
return (
<SuccessScreen
type="swap-success"
client={props.client}
completedStatuses={screen.completedStatuses}
onDone={() => {
@@ -1,21 +1,27 @@
import { useMemo } from "react";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js";
import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js";
import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js";
import type { ActiveWalletInfo } from "./types.js";
export function useActiveWalletInfo(): ActiveWalletInfo | undefined {
export function useActiveWalletInfo(
activeWalletOverride?: Wallet,
): ActiveWalletInfo | undefined {
const activeAccount = useActiveAccount();
const activeWallet = useActiveWallet();
const activeChain = useActiveWalletChain();
return useMemo(() => {
return activeAccount && activeWallet && activeChain
const wallet = activeWalletOverride || activeWallet;
const chain = activeWalletOverride?.getChain() || activeChain;
const account = activeWalletOverride?.getAccount() || activeAccount;
return wallet && chain && account
? {
activeChain,
activeWallet,
activeAccount,
activeChain: chain,
activeWallet: wallet,
activeAccount: account,
}
: undefined;
}, [activeAccount, activeWallet, activeChain]);
}, [activeAccount, activeWallet, activeChain, activeWalletOverride]);
}
@@ -10,6 +10,8 @@ import {
radius,
spacing,
} from "../../../../core/design-system/index.js";
import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js";
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
import { Container, noScrollBar } from "../../components/basic.js";
import { Button } from "../../components/buttons.js";
import { Img } from "../../components/Img.js";
@@ -50,8 +52,7 @@ function findChain(chains: BridgeChain[], activeChainId: number | undefined) {
return chains.find((chain) => chain.chainId === activeChainId);
}
const ALL_TOKENS_LIMIT = 100;
const OWNED_TOKENS_LIMIT = 50;
const INITIAL_LIMIT = 100;
/**
* @internal
@@ -60,8 +61,12 @@ export function SelectToken(props: SelectTokenUIProps) {
const chainQuery = useBridgeChains(props.client);
const [search, _setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 500);
const [allTokensLimit, setAllTokensLimit] = useState(ALL_TOKENS_LIMIT);
const [ownedTokensLimit, setOwnedTokensLimit] = useState(OWNED_TOKENS_LIMIT);
const [limit, setLimit] = useState(INITIAL_LIMIT);
const setSearch = useCallback((search: string) => {
_setSearch(search);
setLimit(INITIAL_LIMIT);
}, []);
const [_selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
undefined,
@@ -79,15 +84,15 @@ export function SelectToken(props: SelectTokenUIProps) {
client: props.client,
chainId: selectedChain?.chainId,
search: debouncedSearch,
limit: allTokensLimit,
limit,
offset: 0,
});
// owned tokens
const ownedTokensQuery = useTokenBalances({
clientId: props.client.clientId,
client: props.client,
chainId: selectedChain?.chainId,
limit: ownedTokensLimit,
limit,
page: 1,
walletAddress: props.activeWalletInfo?.activeAccount.address,
});
@@ -104,62 +109,43 @@ export function SelectToken(props: SelectTokenUIProps) {
});
}, [ownedTokensQuery.data?.tokens, debouncedSearch]);
const setSearch = useCallback((search: string) => {
_setSearch(search);
setAllTokensLimit(ALL_TOKENS_LIMIT);
setOwnedTokensLimit(OWNED_TOKENS_LIMIT);
}, []);
const isFetching = tokensQuery.isFetching || ownedTokensQuery.isFetching;
return (
<SelectTokenUI
{...props}
ownedTokens={{
data: filteredOwnedTokens || [],
isFetching: ownedTokensQuery.isFetching,
showMore: ownedTokensQuery.data?.pagination.hasMore
? () => {
setOwnedTokensLimit(ownedTokensLimit + OWNED_TOKENS_LIMIT);
}
: undefined,
}}
allTokens={{
data: tokensQuery.data || [],
isFetching: tokensQuery.isFetching,
showMore:
tokensQuery.data?.length === allTokensLimit
? () => {
setAllTokensLimit(allTokensLimit + ALL_TOKENS_LIMIT);
}
: undefined,
}}
ownedTokens={filteredOwnedTokens || []}
allTokens={tokensQuery.data || []}
isFetching={isFetching}
selectedChain={selectedChain}
setSelectedChain={setSelectedChain}
search={search}
setSearch={setSearch}
selectedToken={props.selectedToken}
setSelectedToken={props.setSelectedToken}
showMore={
tokensQuery.data?.length === limit
? () => {
setLimit(limit + INITIAL_LIMIT);
}
: undefined
}
/>
);
}
function SelectTokenUI(
props: SelectTokenUIProps & {
allTokens: {
data: Token[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
ownedTokens: {
data: TokenBalance[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
ownedTokens: TokenBalance[];
allTokens: Token[];
isFetching: boolean;
selectedChain: BridgeChain | undefined;
setSelectedChain: (chain: BridgeChain) => void;
search: string;
setSearch: (search: string) => void;
selectedToken: TokenSelection | undefined;
setSelectedToken: (token: TokenSelection) => void;
showMore: (() => void) | undefined;
},
) {
const isMobile = useIsMobile();
@@ -169,7 +155,7 @@ function SelectTokenUI(
// show tokens with icons first
const sortedOwnedTokens = useMemo(() => {
return props.ownedTokens.data.sort((a, b) => {
return props.ownedTokens.sort((a, b) => {
if (a.icon_uri && !b.icon_uri) {
return -1;
}
@@ -178,11 +164,23 @@ function SelectTokenUI(
}
return 0;
});
}, [props.ownedTokens.data]);
}, [props.ownedTokens]);
const otherTokens = useMemo(() => {
const ownedTokenSet = new Set(
sortedOwnedTokens.map((t) =>
`${t.token_address}-${t.chain_id}`.toLowerCase(),
),
);
return props.allTokens.filter(
(token) =>
!ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()),
);
}, [props.allTokens, sortedOwnedTokens]);
// show tokens with icons first
const sortedAllTokens = useMemo(() => {
return props.allTokens.data.sort((a, b) => {
const sortedOtherTokens = useMemo(() => {
return otherTokens.sort((a, b) => {
if (a.iconUri && !b.iconUri) {
return -1;
}
@@ -191,7 +189,7 @@ function SelectTokenUI(
}
return 0;
});
}, [props.allTokens.data]);
}, [otherTokens]);
// desktop
if (!isMobile) {
@@ -223,20 +221,15 @@ function SelectTokenUI(
}}
isMobile={false}
selectedToken={props.selectedToken}
ownedTokens={{
...props.ownedTokens,
data: sortedOwnedTokens,
}}
allTokens={{
...props.allTokens,
data: sortedAllTokens,
}}
isFetching={props.isFetching}
ownedTokens={props.ownedTokens}
otherTokens={sortedOtherTokens}
showMore={props.showMore}
selectedChain={props.selectedChain}
onSelectChain={() => setScreen("select-chain")}
client={props.client}
search={props.search}
setSearch={props.setSearch}
isWalletConnected={props.activeWalletInfo !== undefined}
/>
</Container>
</Container>
@@ -246,20 +239,15 @@ function SelectTokenUI(
if (screen === "select-token") {
return (
<TokenSelectionScreen
isWalletConnected={props.activeWalletInfo !== undefined}
onSelectToken={(token) => {
props.setSelectedToken(token);
props.onClose();
}}
selectedToken={props.selectedToken}
ownedTokens={{
...props.ownedTokens,
data: sortedOwnedTokens,
}}
allTokens={{
...props.allTokens,
data: sortedAllTokens,
}}
isFetching={props.isFetching}
ownedTokens={props.ownedTokens}
otherTokens={sortedOtherTokens}
showMore={props.showMore}
selectedChain={props.selectedChain}
isMobile={true}
onSelectChain={() => setScreen("select-chain")}
@@ -447,36 +435,17 @@ function TokenSelectionScreen(props: {
client: ThirdwebClient;
search: string;
setSearch: (search: string) => void;
isFetching: boolean;
ownedTokens: TokenBalance[];
otherTokens: Token[];
showMore: (() => void) | undefined;
selectedToken: TokenSelection | undefined;
onSelectToken: (token: TokenSelection) => void;
isWalletConnected: boolean;
allTokens: {
data: Token[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
ownedTokens: {
data: TokenBalance[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
}) {
const noAllTokensFound =
!props.allTokens.isFetching && props.allTokens.data.length === 0;
const noOwnedTokensFound =
!props.ownedTokens.isFetching && props.ownedTokens.data.length === 0;
const [activeTab, setActiveTab] = useState<"owned" | "all">("all");
const showSkeletons =
(props.allTokens.isFetching && activeTab === "all") ||
(props.ownedTokens.isFetching && activeTab === "owned");
const showNoTokensFound =
(activeTab === "all" && noAllTokensFound) ||
(activeTab === "owned" && noOwnedTokensFound);
const theme = useCustomTheme();
const noTokensFound =
!props.isFetching &&
props.otherTokens.length === 0 &&
props.ownedTokens.length === 0;
return (
<Container fullHeight flex="column">
@@ -534,28 +503,7 @@ function TokenSelectionScreen(props: {
/>
</Container>
<Spacer y="sm" />
{/* tabs */}
{props.isWalletConnected && (
<Container
flex="row"
gap="xs"
px="md"
style={{ borderBottom: `1px solid ${theme.colors.borderColor}` }}
>
<TabButton
isSelected={activeTab === "all"}
onSelect={() => setActiveTab("all")}
label="All Tokens"
/>
<TabButton
isSelected={activeTab === "owned"}
onSelect={() => setActiveTab("owned")}
label="Your Tokens"
/>
</Container>
)}
<Spacer y="xs" />
<Container
pb="md"
@@ -571,17 +519,36 @@ function TokenSelectionScreen(props: {
paddingBottom: spacing.md,
}}
>
{props.isWalletConnected && <Spacer y="xxs" />}
{showSkeletons &&
{props.isFetching &&
new Array(20).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TokenButtonSkeleton key={i} />
))}
{activeTab === "owned" &&
!props.ownedTokens.isFetching &&
props.ownedTokens.data.map((token) => (
{!props.isFetching && props.ownedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
>
<WalletDotIcon size="14" />
<Text
size="sm"
color="secondaryText"
style={{
overflow: "unset",
}}
>
Your Tokens
</Text>
</Container>
)}
{!props.isFetching &&
props.ownedTokens.map((token) => (
<TokenButton
key={token.token_address}
token={token}
@@ -596,9 +563,33 @@ function TokenSelectionScreen(props: {
/>
))}
{activeTab === "all" &&
!props.allTokens.isFetching &&
props.allTokens.data.map((token) => (
{!props.isFetching && props.ownedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
style={{
marginTop: spacing.sm,
}}
>
<CoinsIcon size="14" />
<Text
size="sm"
color="secondaryText"
style={{
overflow: "unset",
}}
>
Other Tokens
</Text>
</Container>
)}
{!props.isFetching &&
props.otherTokens.map((token) => (
<TokenButton
key={token.address}
token={token}
@@ -613,31 +604,19 @@ function TokenSelectionScreen(props: {
/>
))}
{activeTab === "all" && props.allTokens.showMore && (
{props.showMore && (
<Button
variant="secondary"
fullWidth
onClick={() => {
props.allTokens.showMore?.();
props.showMore?.();
}}
>
Load More
</Button>
)}
{activeTab === "owned" && props.ownedTokens.showMore && (
<Button
variant="secondary"
fullWidth
onClick={() => {
props.ownedTokens.showMore?.();
}}
>
Load More
</Button>
)}
{showNoTokensFound && (
{noTokensFound && (
<div
style={{
flex: 1,
@@ -669,45 +648,3 @@ const LeftContainer = /* @__PURE__ */ StyledDiv((_) => {
position: "relative",
};
});
function TabButton(props: {
isSelected: boolean;
onSelect: () => void;
label: string;
}) {
const theme = useCustomTheme();
return (
<div
style={{
paddingBottom: "4px",
position: "relative",
}}
>
<Button
variant="ghost-solid"
onClick={props.onSelect}
style={{
fontSize: fontSize.sm,
padding: `10px ${spacing.xs}`,
color: props.isSelected
? theme.colors.primaryText
: theme.colors.secondaryText,
}}
>
{props.label}
</Button>
{props.isSelected && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: "-1.5px",
borderBottom: `2px solid ${theme.colors.primaryText}`,
}}
></div>
)}
</div>
);
}
@@ -4,6 +4,7 @@ import { tokens } from "../../../../../bridge/Token.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import { isAddress } from "../../../../../utils/address.js";
import { getThirdwebBaseUrl } from "../../../../../utils/domains.js";
import { getClientFetch } from "../../../../../utils/fetch.js";
export function useTokens(options: {
client: ThirdwebClient;
@@ -72,7 +73,7 @@ type TokenBalancesResponse = {
};
export function useTokenBalances(options: {
clientId: string;
client: ThirdwebClient;
page: number;
limit: number;
walletAddress: string | undefined;
@@ -101,11 +102,8 @@ export function useTokenBalances(options: {
url.searchParams.set("sortOrder", "desc");
url.searchParams.set("includeWithoutPrice", "false"); // filter out tokens with no price
const response = await fetch(url.toString(), {
headers: {
"x-client-id": options.clientId,
},
});
const clientFetch = getClientFetch(options.client);
const response = await clientFetch(url.toString());
if (!response.ok) {
throw new Error(
@@ -37,7 +37,8 @@ export type PaymentMethod =
payerWallet: Wallet;
originToken: TokenWithPrices;
balance: bigint;
quote: Quote;
quote?: Quote;
hasEnoughBalance: boolean;
}
| {
type: "fiat";
@@ -75,6 +75,7 @@ const meta: Meta<typeof SuccessScreen> = {
windowAdapter: webWindowAdapter,
client: storyClient,
hasPaymentId: false,
type: "payment-success",
},
component: SuccessScreen,
decorators: [
@@ -94,6 +95,12 @@ export const Basic: Story = {
args: {},
};
export const BasicSwapSuccess: Story = {
args: {
type: "swap-success",
},
};
export const OnrampPayment: Story = {
args: {
completedStatuses: mockOnrampCompletedStatuses,
+3 -3
View File
@@ -25,7 +25,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "0.525.0",
"next": "15.3.5",
"next": "15.3.6",
"next-themes": "^0.4.6",
"prettier": "3.6.2",
"react-children-utilities": "^2.10.0",
@@ -50,8 +50,8 @@
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/nextjs": "9.0.15",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"autoprefixer": "^10.4.21",
"postcss": "8.5.6",
"storybook": "9.0.15",
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.197
## 0.2.196
## 0.2.195
## 0.2.194
## 0.2.193
+1 -1
View File
@@ -56,5 +56,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.194"
"version": "0.2.197"
}

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