Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a5345808ec Version Packages (#7640)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-21 02:22:54 -07:00
Joaquim VergesandGitHub bfa0a42219 [SDK] Fix TransactionWidget when wallet has enough currency (#7656) 2025-07-21 02:13:17 -07:00
426bb8bbb2 [SDK] Fix: fixes auto-connect on native <ConnectEmbed /> component (#7650)
Co-authored-by: Joaquim Verges <[email protected]>
2025-07-20 18:09:51 -07:00
823af013ae [SDK] Fix: update hooks to use core transaction types (#7647)
Co-authored-by: Joaquim Verges <[email protected]>
2025-07-20 18:02:09 -07:00
gregandGitHub 98aea1e0b9 [Portal] Payments Docs (#7651) 2025-07-20 17:46:20 -07:00
Joaquim VergesandGitHub 620e294f8c [Engine] Update server wallet config types for EOA and EIP-7702 execution (#7652) 2025-07-20 17:43:13 -07:00
Joaquim VergesandGitHub 84eaded152 [Dashboard] Show EOA server wallets by default (#7645) 2025-07-20 16:26:55 -07:00
MananTank 31d7173432 Dashboard: Show upsell card on support page for free plan (#7648)
<!--

## 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 enhances the support functionality within the dashboard, particularly for teams on a free billing plan. It adds checks for the billing plan, modifies the `SupportHeader`, and integrates upsell content to encourage users to upgrade their plans.

### Detailed summary
- `SupportHeader` now accepts `isFreePlan` prop to conditionally render buttons.
- Redirects free plan users from creating support cases.
- Displays upsell content for free plan users in the support page.
- Added functionality to show/hide support forms based on billing plan.
- Updated `CreateSupportCase` component to handle dialog state for support case creation.

> ✨ 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**
  * Free plan users now see an upsell prompt encouraging upgrade to access the Support Center, with a clear list of benefits.
  * The Support Center title has been updated for improved clarity.
  * Support case creation is disabled for free plan users, showing an informative message with a billing link instead.
  * Conditional support action buttons are shown only for paid plans, adapting to the user's context.

* **Improvements**
  * Enhanced visual styling and layout for benefit descriptions in upsell prompts.
  * Error messages in the Support Center now display detailed information for easier troubleshooting.
  * Dialogs for creating support cases now open and close with explicit user control for better interaction.
  * The chat button is hidden on the Support Center page to reduce UI clutter.

* **Bug Fixes**
  * Free plan users are prevented from accessing the support case creation page.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-18 21:17:54 +00:00
Yash094 a48329b697 [dashboard] - support in dashboard (#7608)
<!--

## 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 restructuring the support ticketing system by moving components and modifying links to streamline support interactions within the application.

### Detailed summary
- Deleted multiple support-related files and components.
- Changed `getTeamById` function to remove the `export` keyword.
- Updated support links to point to `/team/~/~/support`.
- Added new support ticket types and forms.
- Introduced new components for handling support tickets.
- Implemented ticket status handling and filtering.
- Enhanced the support case list display with filtering options.
- Created API endpoints for support ticket management.
- Integrated dynamic imports for various support forms.

> The following files were skipped due to too many changes: `apps/dashboard/src/@/components/chat/CustomChatContent.tsx`, `apps/dashboard/src/@/components/chat/CustomChats.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/CreateSupportCase.tsx`

> ✨ 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**
  * Unified support ticket system integrated into team dashboards with AI chat assistant and dynamic product-specific support forms.
  * Support ticket listing with status filtering, search, detailed conversation views, and reply functionality.
  * Added "Support" link in team sidebar footer; updated support headers, layouts, and navigation flows.
  * Introduced monochrome mode for mini logo and refined chat UI elements.

* **Bug Fixes / Improvements**
  * Enhanced chat component styling and user experience.
  * Removed obsolete "Support" links from navigation menus and headers.
  * Updated button labels, including changing "Ask AI Assistant" to "Get Help".

* **Chores**
  * Removed legacy support ticket creation pages, components, and client/server logic to consolidate support workflows.
  * Cleaned deprecated components, imports, redirects, and updated error messages to point to the new support section.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-18 19:09:16 +00:00
Joaquim VergesandGitHub 6e62a3aa5c [Docs] Add Ecosystem Wallet to sidebar and update documentation (#7643) 2025-07-18 16:47:56 +12:00
Jonas DanielsandGitHub 746b0c9d8b Add mcp to team capabilities (#7636) 2025-07-17 17:46:55 -07:00
gregandGitHub 0f38a1287a [SDK] Feature: Hide the TransactionWidget contract label if unknown (#7642) 2025-07-17 16:32:05 -07:00
gregandGitHub 61bfebf5d8 Chore: Use /search route for routes page (#7639) 2025-07-17 13:40:29 -07:00
170c377445 Handle token balance retrieval failures (#7600)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-17 13:40:15 -07:00
Yash094 4cbf67181d Add etherlink chain (#7634)
<!--

## 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 new blockchain definitions for the Etherlink mainnet and testnet, enhancing the `thirdweb` package with additional chain support.

### Detailed summary
- Added `etherlink` mainnet definition in `packages/thirdweb/src/chains/chain-definitions/etherlink.ts`.
- Added `etherlinkTestnet` definition in `packages/thirdweb/src/chains/chain-definitions/etherlink-testnet.ts`.
- Exported `etherlink` and `etherlinkTestnet` in `packages/thirdweb/src/exports/chains.ts`.

> ✨ 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 support for the Etherlink mainnet and Etherlink testnet blockchain networks.
  * Users can now interact with these new chains within the platform.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 19:24:21 +00:00
arcoraven 6bf6108beb chore: webhooks docs (#7604)
<!--

## 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 a new `Webhooks` section in the application, providing a sidebar navigation item, a dedicated layout, and detailed documentation on webhooks, including their configuration, management, and security practices.

### Detailed summary
- Added `sidebar` configuration for `Webhooks` in `sidebar.tsx`.
- Introduced `SDKCard` for `Webhooks` in `page.tsx`.
- Renamed `BAST_URL` to `BASE_URL` in `metadata.ts`.
- Created a new `Layout` component for `Webhooks` in `layout.tsx`.
- Defined metadata for `Webhooks` in `layout.tsx`.
- Added comprehensive documentation for webhooks in `page.mdx`, covering setup, management, event handling, and security.

> ✨ 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**
  * Introduced a new "Webhooks" section in the backend APIs grid, accessible from the main portal.
  * Added comprehensive documentation for thirdweb Webhooks, including setup, management, security, and troubleshooting guides.
  * Implemented a dedicated sidebar and layout for the Webhooks documentation page for easier navigation.

* **Documentation**
  * Provided detailed instructions and code examples for implementing and verifying webhook integrations.

* **Bug Fixes**
  * Corrected a typo in the base URL constant used for metadata generation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 19:12:38 +00:00
Phillip HoandGitHub ce85be3346 chore: accordion, rename insight topic, test webhook endpoint (#7635) 2025-07-17 20:57:22 +08:00
MananTank e125e743a3 Dashboard: Temporarily remove gas sponsorship option in token creation (#7631)
<!--

## 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 temporarily disabling the gasless feature in two components: `launch-token.tsx` and `launch-nft.tsx`. The condition for enabling gasless functionality has been commented out and replaced with a static `false` value.

### Detailed summary
- In `launch-token.tsx`:
  - `canEnableGasless` is set to `false` with a comment about enabling it later.
  - `showGaslessSection` is set to `false`.

- In `launch-nft.tsx`:
  - `canEnableGasless` is set to `false` with a comment about enabling it later.
  - `showGaslessSection` is set to `false`.

> ✨ 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**
  * The gasless transaction feature and related UI have been temporarily disabled for both NFT and token launch flows.

* **Chores**
  * Updated internal logic to prevent access to the gasless sponsorship section until further notice.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 17:35:01 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
05da842b7a Version Packages (#7616)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-16 10:14:03 -07:00
MananTank 216b70475e Dashboard, Portal, Playground: Rename leftover usage of 'Universal Bridge' to 'Payments' in UI (#7611)
<!--

## 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 renaming references from `Universal Bridge` to `Payments` across various files in the codebase, reflecting a rebranding initiative.

### Detailed summary
- Changed `name` and `title` from `Universal Bridge` to `Payments` in multiple files.
- Updated labels and descriptions in UI components to reflect the new branding.
- Modified sidebar links and metadata to align with the new `Payments` terminology.
- Adjusted documentation references to `Payments` from `Universal Bridge`.

> ✨ 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

* **Style**
  * Updated all user-facing references of "Universal Bridge" to "Payments" across banners, page titles, headings, navigation links, sidebars, and documentation content for a consistent branding experience.

* **Documentation**
  * Revised documentation text, metadata, and quickstart guides to reflect the new "Payments" terminology and branding.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 16:20:48 +00:00
iuwqyir 9d259d0836 add insight filter selection to webhook creation and update (#7598)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the webhook configuration components by introducing optional support for `ThirdwebClient` and `supportedChainIds`. It improves error handling and validation, particularly in the context of filtering for event and transaction webhooks.

### Detailed summary
- Made `thirdwebClient` optional in `useAbiProcessing` hook.
- Updated `CreateWebhookConfigModal`, `EditWebhookConfigModal`, `WebhooksOverview` to accept `client` and `supportedChainIds`.
- Enhanced error handling in `useQuery` for missing `thirdwebClient`.
- Introduced separate forms for event and transaction filters in `TopicSelectorModal`.
- Improved validation and fetching of ABIs in the filter forms.
- Added support for displaying ABI fetch status and results.
- Enhanced UI elements to accommodate new filter configurations.

> ✨ 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**
  * Enhanced webhook topic selection with advanced, form-driven filtering for contract webhook topics, including multi-network and ABI-based options.
  * Added support for passing client and supported chain information throughout the webhooks dashboard for improved filtering and configuration.

* **Improvements**
  * Increased dialog width for filter configuration to improve usability.
  * Streamlined handling of webhook topic filters for consistency and flexibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 13:17:01 +00:00
Phillip HoandGitHub ca52cdd3bf chore: /token and /vault landing pages (#7633) 2025-07-16 20:26:13 +08:00
Joaquim VergesandGitHub cf68002922 [Docs] Update page.mdx (#7632)
Signed-off-by: Joaquim Verges <[email protected]>
2025-07-16 02:08:40 -07:00
MananTank 495c7f925d [TOOL-5047] Dashboard: Fix client object missing teamId on contract publish page (#7628)
<!--

## 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 updating the `PublishContractPage` component to utilize the `getUserThirdwebClient` function for client initialization, enhancing user authentication handling during contract publishing.

### Detailed summary
- Added import for `getUserThirdwebClient` from `@/api/auth-token`.
- Removed import for `getClientThirdwebClient` from `@/constants/thirdweb-client.client`.
- Replaced the client initialization in `ContractPublishForm` with `userThirdwebClient` from `getUserThirdwebClient`.

> ✨ 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

* **Refactor**
  * Updated the way the client is obtained for the contract publish form, improving consistency in client retrieval. No visible changes to user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 20:09:58 +00:00
iuwqyir 32add5f602 Display webhook secret in dashboard (#7610)
```
<!--

## title your PR with this format: "[Dashboard] Feature: Display and Mask Webhook Secret in Contracts Webhooks List"

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

## Notes for the reviewer

This PR introduces a new column to the Contracts Webhooks table to display the `webhook_secret`.

Key points:
- The secret is masked for security, showing `**********` followed by the last 3 characters (e.g., `**********xyz`).
- The full secret can be copied to the clipboard using the adjacent copy button.
- No backend changes were required as the `webhook_secret` is already available in the `WebhookResponse`.

## How to test

1. Navigate to a project's webhooks page on the dashboard (`/dashboard/[team_slug]/[project_slug]/webhooks`).
2. Observe the new "Webhook Secret" column in the table.
3. Verify that the secrets are displayed in the masked format (`**********xyz`).
4. Click the copy icon next to a masked secret and confirm that the full secret is copied to your clipboard.

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C085X0VQCF3/p1752487882125419?thread_ts=1752487882.125419&cid=C085X0VQCF3)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new function to mask webhook secrets for improved security and updates the `WebhooksTable` component to display the masked secret while allowing users to copy the original secret.

### Detailed summary
- Added `maskWebhookSecret` function to mask webhook secrets.
- Updated `WebhooksTable` to include a new column for "Webhook Secret."
- Displayed masked secret in the table with an option to copy the original secret.

> ✨ 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 a new "Webhook Secret" column to the webhooks table, displaying masked webhook secrets for improved privacy.
  * Included a copy button to easily copy the full unmasked webhook secret to the clipboard.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 19:59:09 +00:00
gregandGitHub 5f74b3ca3e Chore: Update UB jsdocs to include types (#7629) 2025-07-15 11:50:42 -07:00
GiselleNessi 761cde6a32 [portal] add new support article (#7595)
<!--

## 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 `knowledge-base` section by adding new assets and a comprehensive guide for bulk updating NFT metadata, specifically for ERC-721A contracts.

### Detailed summary
- Added multiple image assets to the `knowledge-base`.
- Introduced a new guide in `page.mdx` for bulk updating NFT metadata.
- Explained concepts of batch and base URI in the context of NFTs.
- Provided step-by-step instructions for updating NFT metadata efficiently.

> ✨ 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 a new sidebar entry for "Bulk update NFT metadata" under the "Contracts" section in the Troubleshoot category.
  * Introduced a comprehensive step-by-step guide on how to update metadata for multiple NFTs in bulk, including instructions, images, and helpful resources.

* **Documentation**
  * Expanded the knowledge base with detailed troubleshooting documentation for bulk updating NFT metadata.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 16:55:09 +00:00
Joaquim VergesandGitHub 9546b927d9 [Docs] Redesign sidebar and update wallet documentation (#7602) 2025-07-15 01:26:32 -07:00
jnsdls f82f885dad Update nebula rateLimit type to include perSecond and perMinute (#7614)
# Update Nebula Rate Limit Type

This PR updates the Nebula rate limit type to provide more granular control by replacing the single `rateLimit` number with an object containing `perSecond` and `perMinute` properties. The change affects:

- The `TeamCapabilities` type in the service-utils package
- Corresponding mock data and stubs in the dashboard app
- Added a changeset to document this patch update
2025-07-15 00:12:41 +00:00
jnsdls 34d2fddf49 Update error response URL for project settings (#7613)
# Update error response URL in service-utils

This PR updates the error response URLs in the service-utils package to point to the project-specific settings page instead of the generic API key creation page. The new URLs follow the format `https://thirdweb.com/${team.slug}/${project.slug}/settings`, providing users with a direct link to manage restrictions for their specific project.

The changes affect error messages for:
- Unauthorized domains
- Unauthorized bundle IDs
- Unauthorized services
- Unauthorized service actions

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Updated error messages for unauthorized access to provide project-specific URLs, directing users to the relevant project settings page for more accurate guidance.

* **Chores**
  * Added documentation to track the patch update related to error response URLs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 00:12:41 +00:00
a7544f75ce [Portal] Add next button to inner page footers (#7605)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-14 15:46:21 -07:00
gregandGitHub bd3d645be6 [SDK] Fix: Adds BuyWidget fallback when native isn't supported (#7601) 2025-07-14 12:06:18 -07:00
MananTank 5fedc2c040 [TOOL-5037] Dashboard: Remove autoConnect on /login page (#7609)
<!--

## 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 import paths for better organization and consistency across the codebase, as well as enhancing the login and onboarding components, including UI adjustments and functionality updates.

### Detailed summary
- Updated import paths to use absolute imports instead of relative paths.
- Modified the `DashboardLayout` to enable auto-connect.
- Enhanced the `LoginPage` with a new footer message regarding phone login support.
- Adjusted the `Toaster` component to simplify its usage.
- Improved onboarding UI components for better user experience.

> ✨ 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 an option to enable automatic wallet connection in the app layout.

* **Refactor**
  * Standardized import paths across the dashboard for improved maintainability and clarity.
  * Updated usage of the notification component to a local version with simplified theming.
  * Improved layout and messaging on the login page, moving phone login deprecation notice to a dedicated footer and refining spacing.

* **Documentation**
  * Enhanced SEO and social sharing metadata on the login page.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 18:36:51 +00:00
Yash094 6c8a7cf2ba fix playground payments widget (#7607)
<!--

## 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 updating the `CodeGen` component in the `playground-web` application by renaming a prop for consistency and clarity.

### Detailed summary
- Changed the prop name from `token` to `tokenAddress` in the `CodeGen` component.
- Maintained existing conditional rendering for other props such as `seller`, `title`, `image`, `description`, `paymentMethods`, and `currency`.

> ✨ 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

* **Refactor**
  * Updated the generated code output to use the prop name `tokenAddress` instead of `token` when displaying the token address in the code example. This change does not affect any other visible functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 18:01:58 +00:00
Phillip HoandGitHub 4f02f6eeb7 chore: add webhook secret to table, fix api error (#7606) 2025-07-14 15:55:21 +08:00
72f75ea40b Update converter SDK reference (#7603)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-14 12:53:52 +08:00
486 changed files with 12736 additions and 8730 deletions
+2
View File
@@ -62,4 +62,6 @@ module.exports = [
// -- universal bridge landing pages --
"/universal-bridge-regions/:region_slug",
"/enterprise",
"/token",
"/vault",
];
+5
View File
@@ -448,6 +448,11 @@ async function redirects() {
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
{
source: "/support/:path*",
destination: "/team/~/~/support",
permanent: false,
},
];
}
@@ -12,7 +12,7 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import { isVercel } from "@/utils/vercel";
import { verifyTurnstileToken } from "./verifyTurnstileToken";
import { verifyTurnstileToken } from "../../app/login/verifyTurnstileToken";
export async function getLoginPayload(
params: GenerateLoginPayloadParams,
+143
View File
@@ -0,0 +1,143 @@
"use server";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { SupportTicket } from "../../app/(app)/team/[team_slug]/(team)/~/support/types/tickets";
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token";
const ESCALATION_FEEDBACK_RATING = 9999;
export async function createSupportTicket(params: {
message: string;
teamSlug: string;
teamId: string;
title: string;
conversationId?: string;
}): Promise<{ data: SupportTicket } | { error: string }> {
const token = await getAuthToken();
if (!token) {
return { error: "No auth token available" };
}
try {
const walletAddress = await getAuthTokenWalletAddress();
const encodedTeamSlug = encodeURIComponent(params.teamSlug);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations`;
// Build the payload for creating a conversation
// If the message does not already include wallet address, prepend it
let message = params.message;
if (!message.includes("Wallet address:")) {
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`;
}
const payload = {
markdown: message.trim(),
title: params.title,
};
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Accept-Encoding": "identity",
};
const response = await fetch(apiUrl, {
body,
headers,
method: "POST",
});
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
}
const createdConversation: SupportTicket = await response.json();
// Escalate to SIWA feedback endpoint if conversationId is provided
if (params.conversationId) {
try {
const siwaUrl = process.env.NEXT_PUBLIC_SIWA_URL;
if (siwaUrl) {
await fetch(`${siwaUrl}/v1/chat/feedback`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(params.teamId ? { "x-team-id": params.teamId } : {}),
},
body: JSON.stringify({
conversationId: params.conversationId,
feedbackRating: ESCALATION_FEEDBACK_RATING,
}),
});
}
} catch (error) {
// Log error but don't fail the ticket creation
console.error("Failed to escalate to SIWA feedback:", error);
}
}
return { data: createdConversation };
} catch (error) {
return {
error: `Failed to create support ticket: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}
export async function sendMessageToTicket(request: {
ticketId: string;
teamSlug: string;
teamId: string;
message: string;
}): Promise<{ success: true } | { error: string }> {
if (!request.ticketId || !request.teamSlug) {
return { error: "Ticket ID and team slug are required" };
}
const token = await getAuthToken();
if (!token) {
return { error: "No auth token available" };
}
try {
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
const encodedTicketId = encodeURIComponent(request.ticketId);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`;
// Append /unthread send for customer messages to ensure proper routing
const messageWithUnthread = `${request.message.trim()}\n/unthread send`;
const payload = {
markdown: messageWithUnthread,
};
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Accept-Encoding": "identity",
...(request.teamId ? { "x-team-id": request.teamId } : {}),
};
const response = await fetch(apiUrl, {
body,
headers,
method: "POST",
});
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
}
return { success: true };
} catch (error) {
return {
error: `Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export async function service_getTeamBySlug(slug: string) {
return null;
}
export function getTeamById(id: string) {
function getTeamById(id: string) {
return getTeamBySlug(id);
}
+52 -2
View File
@@ -37,7 +37,7 @@ type WebhookConfigsResponse =
};
interface CreateWebhookConfigRequest {
topics: { id: string; filters: object | null }[];
topicIdsWithFilters: { id: string; filters: object | null }[];
destinationUrl: string;
description: string;
isPaused?: boolean;
@@ -76,7 +76,7 @@ type TopicsResponse =
interface UpdateWebhookConfigRequest {
destinationUrl?: string;
topics?: { id: string; filters: object | null }[];
topicIdsWithFilters?: { id: string; filters: object | null }[];
description?: string;
isPaused?: boolean;
}
@@ -310,3 +310,53 @@ export async function deleteWebhookConfig(props: {
status: "success",
};
}
type TestDestinationUrlResponse =
| {
result: {
httpStatusCode: number;
httpResponseBody: string;
};
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export async function testDestinationUrl(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
destinationUrl: string;
}): Promise<TestDestinationUrlResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const resp = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/test-destination-url`,
{
body: JSON.stringify({ destinationUrl: props.destinationUrl }),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!resp.ok) {
return {
body: await resp.text(),
reason: "unknown",
status: "error",
};
}
return await resp.json();
}
@@ -1,6 +1,6 @@
"use client";
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
import { CrownIcon, LockIcon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import type { Team } from "@/api/team";
@@ -111,18 +111,15 @@ export function UpsellContent(props: {
<CardContent className="space-y-6">
{props.benefits && props.benefits.length > 0 && (
<div className="space-y-3">
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
What you'll get:
<h4 className="font-semibold text-foreground text-sm capitalize text-center">
What you'll get
</h4>
<div className="grid gap-2">
<div className="grid gap-1.5">
{props.benefits.map((benefit) => (
<div
className="flex items-center gap-3"
className="flex items-center justify-center gap-3 text-center text-balance"
key={benefit.description}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
<SparklesIcon className="h-3 w-3 text-success-text" />
</div>
<span className="text-sm">{benefit.description}</span>
{benefit.status === "soon" && (
<Badge className="text-xs" variant="secondary">
@@ -116,13 +116,12 @@ export function ChatBar(props: {
) : (
<Button
aria-label="Send"
className="!h-auto w-auto border border-nebula-pink-foreground p-2 disabled:opacity-100"
className="!h-auto w-auto p-2 disabled:opacity-100"
disabled={message.trim() === "" || props.isConnectingWallet}
onClick={() => {
if (message.trim() === "") return;
handleSubmit(message);
}}
variant="pink"
>
<ArrowUpIcon className="size-4" />
</Button>
@@ -1,8 +1,10 @@
"use client";
import { MessageCircleIcon, XIcon } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { createThirdwebClient } from "thirdweb";
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
import { cn } from "@/lib/utils";
@@ -14,22 +16,22 @@ const client = createThirdwebClient({
});
export function CustomChatButton(props: {
isLoggedIn: boolean;
networks: "mainnet" | "testnet" | "all" | null;
isFloating: boolean;
pageType: "chain" | "contract" | "support";
label: string;
examplePrompts: string[];
authToken: string | undefined;
teamId: string | undefined;
authToken: string;
team: Team;
clientId: string | undefined;
requireLogin?: boolean;
}) {
const layoutSegments = useSelectedLayoutSegments();
const [isOpen, setIsOpen] = useState(false);
const [hasBeenOpened, setHasBeenOpened] = useState(false);
const closeModal = useCallback(() => setIsOpen(false), []);
const ref = useRef<HTMLDivElement>(null);
if (layoutSegments[0] === "~" && layoutSegments[1] === "support") {
return null;
}
return (
<>
{/* Inline Button (not floating) */}
@@ -54,14 +56,14 @@ export function CustomChatButton(props: {
ref={ref}
>
{/* Header with close button */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center justify-between border-b px-4 py-4">
<div className="flex items-center gap-2 font-semibold text-lg">
<MessageCircleIcon className="size-5 text-muted-foreground" />
{props.label}
</div>
<Button
aria-label="Close chat"
className="h-auto w-auto p-1 text-muted-foreground"
className="h-auto w-auto p-1 text-muted-foreground rounded-full"
onClick={closeModal}
size="icon"
variant="ghost"
@@ -80,9 +82,7 @@ export function CustomChatButton(props: {
message: prompt,
title: prompt,
}))}
networks={props.networks}
requireLogin={props.requireLogin}
teamId={props.teamId}
team={props.team}
/>
)}
</div>
@@ -1,44 +1,36 @@
"use client";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWalletConnectionStatus } from "thirdweb/react";
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
import { ChatBar } from "./ChatBar";
import type { UserMessage, UserMessageContent } from "./CustomChats";
import { type CustomChatMessage, CustomChats } from "./CustomChats";
import type { ExamplePrompt, NebulaContext } from "./types";
import type { ExamplePrompt } from "./types";
export default function CustomChatContent(props: {
authToken: string | undefined;
teamId: string | undefined;
team: Team;
clientId: string | undefined;
client: ThirdwebClient;
examplePrompts: ExamplePrompt[];
networks: NebulaContext["networks"];
requireLogin?: boolean;
}) {
if (props.requireLogin !== false && !props.authToken) {
return <LoggedOutStateChatContent />;
}
return (
<CustomChatContentLoggedIn
authToken={props.authToken || ""}
client={props.client}
clientId={props.clientId}
examplePrompts={props.examplePrompts}
teamId={props.teamId}
team={props.team}
/>
);
}
function CustomChatContentLoggedIn(props: {
authToken: string;
teamId: string | undefined;
team: Team;
clientId: string | undefined;
client: ThirdwebClient;
examplePrompts: ExamplePrompt[];
@@ -55,6 +47,9 @@ function CustomChatContentLoggedIn(props: {
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
const connectionStatus = useActiveWalletConnectionStatus();
const [showSupportForm, setShowSupportForm] = useState(false);
const [productLabel, setProductLabel] = useState("");
const handleSendMessage = useCallback(
async (userMessage: UserMessage) => {
const abortController = new AbortController();
@@ -96,7 +91,7 @@ function CustomChatContentLoggedIn(props: {
headers: {
Authorization: `Bearer ${props.authToken}`,
"Content-Type": "application/json",
...(props.teamId ? { "x-team-id": props.teamId } : {}),
"x-team-id": props.team.id,
...(props.clientId ? { "x-client-id": props.clientId } : {}),
},
method: "POST",
@@ -132,7 +127,7 @@ function CustomChatContentLoggedIn(props: {
setEnableAutoScroll(false);
}
},
[props.authToken, props.clientId, props.teamId, sessionId],
[props.authToken, props.clientId, props.team.id, sessionId],
);
const handleFeedback = useCallback(
@@ -165,7 +160,7 @@ function CustomChatContentLoggedIn(props: {
headers: {
Authorization: `Bearer ${props.authToken}`,
"Content-Type": "application/json",
...(props.teamId ? { "x-team-id": props.teamId } : {}),
"x-team-id": props.team.id,
},
method: "POST",
});
@@ -188,9 +183,20 @@ function CustomChatContentLoggedIn(props: {
// Consider implementing retry logic here
}
},
[sessionId, props.authToken, props.teamId, messages],
[sessionId, props.authToken, props.team.id, messages],
);
const handleAddSuccessMessage = useCallback((message: string) => {
setMessages((prev) => [
...prev,
{
type: "assistant" as const,
text: message,
request_id: undefined,
},
]);
}, []);
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
return (
<div className="flex grow flex-col overflow-hidden">
@@ -212,6 +218,12 @@ function CustomChatContentLoggedIn(props: {
sessionId={sessionId}
setEnableAutoScroll={setEnableAutoScroll}
useSmallText
showSupportForm={showSupportForm}
setShowSupportForm={setShowSupportForm}
productLabel={productLabel}
setProductLabel={setProductLabel}
team={props.team}
addSuccessMessage={handleAddSuccessMessage}
/>
)}
<ChatBar
@@ -245,42 +257,6 @@ function CustomChatContentLoggedIn(props: {
);
}
function LoggedOutStateChatContent() {
const pathname = usePathname();
return (
<div className="flex grow flex-col items-center justify-center p-4">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-1">
<div className="rounded-full border bg-card p-2">
<NebulaIcon className="size-7 text-muted-foreground" />
</div>
</div>
</div>
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
How can I help you <br className="max-sm:hidden" />
today?
</h1>
<div className="h-3" />
<p className="text-base text-muted-foreground">
Sign in to use AI Assistant
</p>
<div className="h-5" />
<Button asChild>
<Link
className="w-full max-w-96 gap-2"
href={`/login?next=${encodeURIComponent(pathname)}`}
>
Sign in
<ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
);
}
function EmptyStateChatPageContent(props: {
sendMessage: (message: UserMessage) => void;
examplePrompts: { title: string; message: string }[];
@@ -288,9 +264,12 @@ function EmptyStateChatPageContent(props: {
return (
<div className="flex flex-1 flex-col items-center justify-center overflow-auto p-4 ">
<div className="mb-4 flex justify-center">
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/20 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-1">
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/40 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-2">
<NebulaIcon className="size-7 text-nebula-pink-foreground" />
<div className="rounded-full border p-1 bg-muted/20">
<div className="rounded-full border p-2 bg-inverted">
<ThirdwebMiniLogo
isMonoChrome
className="size-7 text-inverted-foreground"
/>
</div>
</div>
</div>
@@ -301,11 +280,12 @@ function EmptyStateChatPageContent(props: {
</h1>
<div className="h-6" />
<div className="flex max-w-lg flex-col flex-wrap justify-center gap-2.5">
<div className="flex flex-col items-center justify-center gap-2.5 overflow-hidden">
{props.examplePrompts.map((prompt) => (
<Button
disabled={false}
key={prompt.title}
className="rounded-full text-xs sm:text-sm truncate bg-card w-fit h-auto py-1.5 whitespace-pre-wrap"
onClick={() =>
props.sendMessage({
content: [
@@ -1,15 +1,26 @@
import {
AlertCircleIcon,
MessageCircleIcon,
ArrowRightIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Team } from "@/api/team";
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { cn } from "@/lib/utils";
import { Reasoning } from "./Reasoning";
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm";
import { Button } from "../ui/button";
import { TextShimmer } from "../ui/text-shimmer";
// Define local types
export type UserMessageContent = { type: "text"; text: string };
@@ -46,11 +57,18 @@ export function CustomChats(props: {
enableAutoScroll: boolean;
useSmallText?: boolean;
sendMessage: (message: UserMessage) => void;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
showSupportForm: boolean;
setShowSupportForm: (v: boolean) => void;
productLabel: string;
setProductLabel: (v: string) => void;
team: Team;
addSuccessMessage?: (message: string) => void;
}) {
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
const scrollAnchorRef = useRef<HTMLDivElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const [supportTicketCreated, setSupportTicketCreated] = useState(false);
// auto scroll to bottom when messages change
// eslint-disable-next-line no-restricted-syntax
@@ -86,6 +104,8 @@ export function CustomChats(props: {
chatScrollContainer.addEventListener("wheel", disableScroll);
}, [setEnableAutoScroll, enableAutoScroll]);
const [showSupportFormDialog, setShowSupportFormDialog] = useState(false);
return (
<div
className="relative flex max-h-full flex-1 flex-col overflow-hidden"
@@ -123,6 +143,66 @@ export function CustomChats(props: {
sendMessage={props.sendMessage}
sessionId={props.sessionId}
/>
{/* Support Case Button/Form in last assistant message */}
{message.type === "assistant" &&
index === props.messages.length - 1 && (
<>
{/* Only show button/form if ticket not created */}
{!supportTicketCreated && (
<div className="mt-3 pl-12">
<Dialog
open={showSupportFormDialog}
onOpenChange={setShowSupportFormDialog}
>
<Button
onClick={() => setShowSupportFormDialog(true)}
size="sm"
variant="outline"
className="rounded-full bg-card"
>
Create Support Case
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
<DialogContent className="p-0 bg-card">
<DynamicHeight>
<DialogHeader className="p-4 lg:p-6 border-b border-dashed space-y-1">
<DialogTitle className="text-xl font-semibold text-foreground tracking-tight">
Create Support Case
</DialogTitle>
<p className="text-muted-foreground text-sm">
Let's create a detailed support case for
our technical team.
</p>
</DialogHeader>
<SupportTicketForm
team={props.team}
productLabel={props.productLabel}
setProductLabel={props.setProductLabel}
conversationId={props.sessionId}
closeForm={() => {
setShowSupportFormDialog(false);
}}
onSuccess={() => {
props.setShowSupportForm(false);
props.setProductLabel("");
setSupportTicketCreated(true);
// Add success message as a regular assistant message
if (props.addSuccessMessage) {
const supportPortalUrl = `/team/${props.team.slug}/~/support`;
props.addSuccessMessage(
`Your support ticket has been created! Our team will get back to you soon. You can also visit the [support portal](${supportPortalUrl}) to track your case.`,
);
}
}}
/>
</DynamicHeight>
</DialogContent>
</Dialog>
</div>
)}
</>
)}
</div>
);
})}
@@ -143,7 +223,7 @@ function RenderMessage(props: {
nextMessage: CustomChatMessage | undefined;
authToken: string;
sessionId: string | undefined;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
}) {
const { message } = props;
@@ -179,13 +259,16 @@ function RenderMessage(props: {
<div
className={cn(
"flex size-9 items-center justify-center rounded-full",
message.type === "assistant" && "border bg-card",
message.type === "assistant" && "border bg-inverted",
message.type === "error" && "border",
message.type === "presence" && "border bg-card",
message.type === "presence" && "border bg-inverted",
)}
>
{(message.type === "presence" || message.type === "assistant") && (
<MessageCircleIcon className="size-5 text-muted-foreground" />
<ThirdwebMiniLogo
className="size-4 text-inverted-foreground"
isMonoChrome
/>
)}
{message.type === "error" && (
@@ -227,7 +310,7 @@ function RenderMessage(props: {
function CustomFeedbackButtons(props: {
message: CustomChatMessage & { type: "assistant" };
messageIndex: number;
onFeedback: (messageIndex: number, feedback: 1 | -1) => void;
onFeedback: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
className?: string;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -240,9 +323,9 @@ function CustomFeedbackButtons(props: {
await props.onFeedback(props.messageIndex, feedback);
} catch (_e) {
// Handle error silently
} finally {
setIsSubmitting(false);
}
setIsSubmitting(false);
};
// Don't show buttons if feedback already given
@@ -251,25 +334,26 @@ function CustomFeedbackButtons(props: {
}
return (
<div className={cn("flex gap-2", props.className)}>
<button
aria-label="Thumbs up"
className="text-muted-foreground transition-colors hover:text-green-500 disabled:opacity-50"
<div className={cn("flex gap-1.5", props.className)}>
<Button
aria-label="Upvote"
variant="outline"
disabled={isSubmitting}
onClick={() => handleFeedback(1)}
type="button"
className="size-8 p-0 rounded-lg bg-card"
>
<ThumbsUpIcon className="size-5" />
</button>
<button
aria-label="Thumbs down"
className="text-muted-foreground transition-colors hover:text-red-500 disabled:opacity-50"
<ThumbsUpIcon className="size-3.5" />
</Button>
<Button
aria-label="Downvote"
variant="outline"
disabled={isSubmitting}
onClick={() => handleFeedback(-1)}
type="button"
className="size-8 p-0 rounded-lg bg-card"
>
<ThumbsDownIcon className="size-5" />
</button>
<ThumbsDownIcon className="size-3.5" />
</Button>
</div>
);
}
@@ -296,7 +380,11 @@ function RenderResponse(props: {
);
case "presence":
return <Reasoning isPending={isMessagePending} texts={message.texts} />;
return (
<div className="h-8 flex items-center">
<TextShimmer text="Reasoning..." />
</div>
);
case "error":
return (
@@ -325,19 +413,15 @@ function StyledMarkdownRenderer(props: {
}) {
return (
<MarkdownRenderer
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
className="[&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
code={{
className: "bg-transparent",
className: "bg-card",
ignoreFormattingErrors: true,
}}
inlineCode={{ className: "border-none" }}
li={{ className: "text-foreground" }}
markdownText={props.text}
p={{
className:
props.type === "assistant"
? "text-foreground"
: "text-foreground leading-normal",
className: props.type === "assistant" ? "" : "leading-normal",
}}
skipHtml
/>
@@ -1,62 +0,0 @@
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { TextShimmer } from "@/components/ui/text-shimmer";
import { cn } from "@/lib/utils";
export function Reasoning(props: { isPending: boolean; texts: string[] }) {
const [_isOpen, setIsOpen] = useState(false);
const isOpen = props.isPending ? true : _isOpen;
const showAll = !props.isPending;
const lastText = props.texts[props.texts.length - 1];
return (
<DynamicHeight>
<Button
className="h-auto w-auto items-center gap-1 px-0 py-1 pb-2 font-normal text-base text-muted-foreground hover:text-foreground hover:no-underline"
onClick={() => setIsOpen(!isOpen)}
size="sm"
variant="link"
>
{props.isPending ? (
<TextShimmer text="Reasoning..." />
) : (
<span className="text-foreground">Reasoning</span>
)}
{!props.isPending && (
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen && "rotate-180",
)}
/>
)}
</Button>
{isOpen && (
<>
{showAll && props.texts.length > 0 && (
<ul className="list-none space-y-1.5">
{props.texts.map((text) => (
<li
className="fade-in-0 animate-in text-muted-foreground text-sm leading-relaxed duration-300"
key={text}
>
{text.trim()}
</li>
))}
</ul>
)}
{!showAll && lastText && (
<div className="text-muted-foreground text-sm leading-relaxed">
{lastText.trim()}
</div>
)}
</>
)}
</DynamicHeight>
);
}
@@ -6,12 +6,6 @@ export type ExamplePrompt = {
// TODO - remove "Nebula" wording and simplify types
export type NebulaContext = {
chainIds: string[] | null;
walletAddress: string | null;
networks: "mainnet" | "testnet" | "all" | null;
};
type NebulaUserMessageContentItem =
| {
type: "image";
@@ -1,7 +1,6 @@
"use client";
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
import { doLogout } from "@app/login/auth-actions";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -14,6 +13,7 @@ import {
useActiveAccount,
useConnectModal,
} from "thirdweb/react";
import { doLogout } from "@/actions/auth-actions";
import { resetAnalytics } from "@/analytics/reset";
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
@@ -47,7 +47,7 @@ export function AnnouncementBanner() {
return (
<AnnouncementBannerUI
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
label="We have re-branded our Engine, Universal Bridge, and Connect products. Please read the full blog post for details on changes"
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
trackingLabel="product-rebrand"
/>
);
@@ -26,7 +26,6 @@ const buttonVariants = cva(
link: "underline-offset-4 hover:underline",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
secondary:
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
@@ -150,9 +150,13 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
/>
)}
<Button asChild className="py-2 text-sm" variant="primary">
<Link href="/support" rel="noopener noreferrer" target="_blank">
Visit support site
<Button asChild className="py-2 text-sm" variant="default">
<Link
href="/team/~/~/support"
rel="noopener noreferrer"
target="_blank"
>
Contact Support
</Link>
</Button>
</div>
+8 -1
View File
@@ -57,7 +57,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
},
nebula: {
enabled: true,
rateLimit: 1000,
rateLimit: {
perSecond: 1000,
perMinute: 1000,
},
},
pay: {
enabled: true,
@@ -82,6 +85,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
totalFileSizeBytesLimit: 1_000_000_000,
},
},
mcp: {
enabled: true,
rateLimit: 10,
},
},
createdAt: new Date().toISOString(),
dedicatedSupportChannel: null,
+3 -3
View File
@@ -4,14 +4,14 @@ import type { JSX } from "react";
const PLEASE_REACH_OUT_MESSAGE = (
<span>
If you believe this is incorrect or the error persists, please visit our{" "}
If you believe this is incorrect or the error persists, please{" "}
<Link
className="font-semibold underline"
href="/support"
href="/team/~/~/support"
rel="noopener noreferrer"
target="_blank"
>
support site
contact support
</Link>
.
</span>
@@ -10,7 +10,7 @@ import {
type SearchParams,
} from "./components/server/routes-table";
const title = "Universal Bridge Routes: Swap, Bridge, and Onramp";
const title = "Payments Routes: Swap, Bridge, and Onramp";
const description =
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
@@ -66,7 +66,7 @@ export default async function RoutesPage(props: {
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with Universal Bridge
Get Started with thirdweb Payments
</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and chain.
@@ -1,6 +1,5 @@
import "server-only";
import type { Address } from "thirdweb";
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import type { Route } from "./types/route";
@@ -10,21 +9,13 @@ export async function getRoutes({
offset,
originQuery,
destinationQuery,
originChainId,
destinationChainId,
originTokenAddress,
destinationTokenAddress,
}: {
limit?: number;
offset?: number;
originQuery?: string;
destinationQuery?: string;
originChainId?: number;
destinationChainId?: number;
originTokenAddress?: Address;
destinationTokenAddress?: Address;
} = {}) {
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes`);
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
if (limit) {
url.searchParams.set("limit", limit.toString());
}
@@ -37,20 +28,7 @@ export async function getRoutes({
if (destinationQuery) {
url.searchParams.set("destinationQuery", destinationQuery);
}
if (originChainId) {
url.searchParams.set("originChainId", originChainId.toString());
}
if (destinationChainId) {
url.searchParams.set("destinationChainId", destinationChainId.toString());
}
if (originTokenAddress) {
url.searchParams.set("originTokenAddress", originTokenAddress);
}
if (destinationTokenAddress) {
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
}
url.searchParams.set("sortBy", "popularity");
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
const routesResponse = await fetch(url, {
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
@@ -3,7 +3,7 @@ import type { ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { mapV4ChainToV5Chain } from "../../../../../../../../@/utils/map-chains";
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
import { ChainIcon } from "../../../../components/server/chain-icon";
import { AddChainToWallet } from "../client/add-chain-to-wallet";
@@ -19,7 +19,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { mapV4ChainToV5Chain } from "../../../../../../@/utils/map-chains";
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
import { StarButton } from "../../components/client/star-button";
import { getChain, getChainMetadata } from "../../utils";
@@ -1,10 +1,10 @@
import { getAddress, getContract, isAddress } from "thirdweb";
import { localhost } from "thirdweb/chains";
import { getUserThirdwebClient } from "@/api/auth-token";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token";
import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides";
export async function getContractPageParamsInfo(params: {
contractAddress: string;
@@ -1,5 +1,5 @@
import { notFound, redirect } from "next/navigation";
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
import { getAuthTokenWalletAddress } from "@/api/auth-token";
import type { ProjectMeta } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types";
import { redirectToContractLandingPage } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
import { buildContractPagePath } from "../../_utils/contract-page-path";
@@ -1,5 +1,5 @@
import type { ThirdwebContract } from "thirdweb";
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
import { getAuthTokenWalletAddress } from "@/api/auth-token";
import { getPublishedByCardProps, PublishedByUI } from "./published-by-ui";
interface PublishedByProps {
@@ -39,8 +39,8 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { tryCatch } from "@/utils/try-catch";
import { getSDKTheme } from "../../../../../../../../../../@/utils/sdk-component-theme";
import { PublicPageConnectButton } from "../../../_components/PublicPageConnectButton";
import { SupplyClaimedProgress } from "../../../_components/supply-claimed-progress";
import { TokenPrice } from "../../../_components/token-price";
@@ -1,6 +1,7 @@
import { ChevronDownIcon } from "lucide-react";
import { headers } from "next/headers";
import Link from "next/link";
import { getAuthToken } from "@/api/auth-token";
import {
Breadcrumb,
BreadcrumbItem,
@@ -14,7 +15,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getAuthToken } from "../../../../../../@/api/auth-token";
import {
AllFilters,
ChainOptionsFilter,
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getAuthToken } from "../../../../../@/api/auth-token";
import { getAuthToken } from "@/api/auth-token";
import {
AllFilters,
ChainOptionsFilter,
@@ -3,8 +3,8 @@ import "server-only";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { cn } from "@/lib/utils";
import { fallbackChainIcon } from "@/utils/chain-icons";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { fallbackChainIcon } from "../../../../../../@/utils/chain-icons";
export async function ChainIcon(props: {
iconUrl?: string;
@@ -2,9 +2,9 @@ import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { fetchDeployMetadata } from "thirdweb/contract";
import { getUserThirdwebClient } from "@/api/auth-token";
import { ContractPublishForm } from "@/components/contract-components/contract-publish-form";
import { getActiveAccountCookie, getJWTCookie } from "@/constants/cookie";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { getLatestPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
@@ -68,14 +68,15 @@ export default async function PublishContractPage(
redirect(`/login?next=${encodeURIComponent(pathname)}`);
}
const userThirdwebClient = await getUserThirdwebClient({
teamId: undefined,
});
return (
<div className="container flex max-w-[1130px] flex-col gap-8 py-8">
<ChakraProviderSetup>
<ContractPublishForm
client={getClientThirdwebClient({
jwt: token,
teamId: undefined,
})}
client={userThirdwebClient}
isLoggedIn={!!token}
onPublishSuccess={async () => {
"use server";
@@ -1,5 +1,5 @@
import { AppFooter } from "@/components/footers/app-footer";
import { ErrorProvider } from "../../../@/contexts/error-handler";
import { ErrorProvider } from "@/contexts/error-handler";
export default function DashboardLayout(props: { children: React.ReactNode }) {
return (
@@ -1,10 +1,10 @@
import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { PublishedContract } from "@/components/contracts/published-contract";
import { Separator } from "@/components/ui/separator";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { getAuthToken } from "../../../../../../@/api/auth-token";
import { getRawAccount } from "../../../../account/settings/getAccount";
import { PublishedActions } from "../../components/contract-actions-published.client";
import { DeployContractHeader } from "../../components/contract-header";
@@ -4,7 +4,7 @@ import { getUserThirdwebClient } from "@/api/auth-token";
import { getProjects } from "@/api/projects";
import { getTeams } from "@/api/team";
import { CustomContractForm } from "@/components/contract-components/contract-deploy-form/custom-contract";
import { loginRedirect } from "../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
type DeployFormForUriProps = {
contractMetadata: FetchDeployMetadataResult | null;
@@ -1,67 +0,0 @@
import { type ReactElement, useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
type ProblemAreaItem = {
label: string;
component: ReactElement;
};
const ACCOUNT_PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Pricing inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Billing inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Usage inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Other",
},
];
export default function AccountSupportForm() {
const [problemArea, setProblemArea] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Problem area"
name="extraInfo_Problem_Area"
onValueChange={setProblemArea}
options={ACCOUNT_PROBLEM_AREAS.map((o) => o.label)}
promptText="Select a problem area"
required={true}
value={problemArea}
/>
{ACCOUNT_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component}
</>
);
}
@@ -1,66 +0,0 @@
import { type ReactElement, useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
type ProblemAreaItem = {
label: string;
component: ReactElement;
};
const OTHER_PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "General inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Security",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Feedback",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Other",
},
];
export default function OtherSupportForm() {
const [problemArea, setProblemArea] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Problem area"
name="extraInfo_Problem_Area"
onValueChange={setProblemArea}
options={OTHER_PROBLEM_AREAS.map((o) => o.label)}
promptText="Select a problem area"
required={true}
value={problemArea}
/>
{OTHER_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component}
</>
);
}
@@ -1,17 +0,0 @@
import { Label } from "@/components/ui/label";
export const AttachmentForm = () => {
return (
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor="files">
Attachments
</Label>
<input
accept="image/*,video/*,"
multiple
name="attachments"
type="file"
/>
</div>
);
};
@@ -1,58 +0,0 @@
import { useId } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type MinimalTeam = {
name: string;
id: string;
};
type Props = {
teams: MinimalTeam[];
selectedTeamId: string | undefined;
onChange: (teamId: string) => void;
};
export const SupportForm_TeamSelection = (props: Props) => {
const selectedTeamName = props.teams.find(
(t) => t.id === props.selectedTeamId,
)?.name;
const teamId = useId();
return (
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={teamId}>
Select Team
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
</Label>
<Select
name="teamId"
onValueChange={(selectedId) => {
props.onChange(selectedId);
}}
value={props.selectedTeamId}
>
<SelectTrigger id={teamId}>
<SelectValue placeholder="Select a Team">
{selectedTeamName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -1,27 +0,0 @@
import { Label } from "@/components/ui/label";
type Props = {
placeholder?: string;
};
const defaultDescription = "@YourHandle";
export const SupportForm_TelegramInput = (props: Props) => {
return (
<div className="flex flex-col items-start gap-2 bg-[#0a0a0a]">
<Label className="relative" htmlFor="telegram">
Telegram
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
</Label>
<input
autoComplete="off"
className="flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
maxLength={50}
name="telegram"
placeholder={props.placeholder ?? defaultDescription}
type="text"
/>
</div>
);
};
@@ -1,208 +0,0 @@
"use server";
import "server-only";
import { getTeamById } from "@/api/team";
import { getAuthTokenWalletAddress } from "../../../../../../@/api/auth-token";
import { getRawAccount } from "../../../../account/settings/getAccount";
import { loginRedirect } from "../../../../login/loginRedirect";
type State = {
success: boolean;
message: string;
};
const UNTHREAD_API_KEY = process.env.UNTHREAD_API_KEY || "";
const planToCustomerId = {
accelerate: process.env.UNTHREAD_ACCELERATE_TIER_ID as string,
free: process.env.UNTHREAD_FREE_TIER_ID as string,
growth: process.env.UNTHREAD_GROWTH_TIER_ID as string,
pro: process.env.UNTHREAD_PRO_TIER_ID as string,
scale: process.env.UNTHREAD_SCALE_TIER_ID as string,
// treat starter as free
starter: process.env.UNTHREAD_FREE_TIER_ID as string,
} as const;
function isValidPlan(plan: string): plan is keyof typeof planToCustomerId {
return plan in planToCustomerId;
}
function prepareEmailTitle(
product: string,
problemArea: string,
email: string,
name: string,
) {
const title =
product && problemArea
? `${product}: ${problemArea} (${email})`
: `New ticket from ${name} (${email})`;
return title;
}
function prepareEmailBody(props: {
product: string;
markdownInput: string;
email: string;
name: string;
extraInfoInput: Record<string, string>;
walletAddress: string;
telegramHandle: string;
}) {
const {
extraInfoInput,
email,
walletAddress,
product,
name,
markdownInput,
telegramHandle,
} = props;
// Update `markdown` to include the infos from the form
const extraInfo = Object.keys(extraInfoInput)
.filter((key) => key.startsWith("extraInfo_"))
.map((key) => {
const prettifiedKey = `# ${key
.replace("extraInfo_", "")
.replaceAll("_", " ")}`;
return `${prettifiedKey}: ${extraInfoInput[key] ?? "N/A"}\n`;
})
.join("");
const markdown = `# Email: ${email}
# Name: ${name}
# Telegram: ${telegramHandle}
# Wallet address: ${walletAddress}
# Product: ${product}
${extraInfo}
# Message:
${markdownInput}
`;
return markdown;
}
export async function createTicketAction(
_previousState: State,
formData: FormData,
) {
const teamId = formData.get("teamId")?.toString();
if (!teamId) {
return {
message: "teamId is required",
success: false,
};
}
const team = await getTeamById(teamId);
if (!team) {
return {
message: `Team with id "${teamId}" not found`,
success: false,
};
}
const [walletAddress, account] = await Promise.all([
getAuthTokenWalletAddress(),
getRawAccount(),
]);
if (!walletAddress || !account) {
loginRedirect("/support");
}
const customerId = isValidPlan(team.supportPlan)
? // fall back to "free" tier
planToCustomerId[team.supportPlan] || planToCustomerId.free
: // fallback to "free" tier
planToCustomerId.free;
const product = formData.get("product")?.toString() || "";
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
const telegramHandle = formData.get("telegram")?.toString() || "";
const title = prepareEmailTitle(
product,
problemArea,
account.email || "",
account.name || "",
);
const keyVal: Record<string, string> = {};
for (const key of formData.keys()) {
keyVal[key] = formData.get(key)?.toString() || "";
}
const markdown = prepareEmailBody({
email: account.email || "",
extraInfoInput: keyVal,
markdownInput: keyVal.markdown || "",
name: account.name || "",
product,
telegramHandle: telegramHandle,
walletAddress: walletAddress,
});
const content = {
customerId,
emailInboxId: process.env.UNTHREAD_EMAIL_INBOX_ID,
markdown,
onBehalfOf: {
email: account.email,
id: account.id,
name: account.name,
},
status: "open",
title,
triageChannelId: process.env.UNTHREAD_TRIAGE_CHANNEL_ID,
type: "email",
};
// check files
const files = formData.getAll("files") as File[];
if (files.length > 10) {
return { message: "You can only attach 10 files at once.", success: false };
}
if (files.some((file) => file.size > 10 * 1024 * 1024)) {
return { message: "The max file size is 20MB.", success: false };
}
// add the content
formData.append("json", JSON.stringify(content));
const KEEP_FIELDS = ["attachments", "json"];
const keys = [...formData.keys()];
// delete everything except attachments off of the form data
for (const key of keys) {
if (!KEEP_FIELDS.includes(key)) {
formData.delete(key);
}
}
// actually create the ticket
const res = await fetch("https://api.unthread.io/api/conversations", {
body: formData,
headers: {
"X-Api-Key": UNTHREAD_API_KEY,
},
method: "POST",
});
if (!res.ok) {
console.error(
"Failed to create ticket",
res.status,
res.statusText,
await res.text(),
);
return {
message: "Failed to create ticket, please try again later.",
success: false,
};
}
return {
message: "Ticket created successfully",
success: true,
};
}
@@ -1,183 +0,0 @@
"use client";
import { SupportForm_SelectInput } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput";
import { SupportForm_TeamSelection } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection";
import { SupportForm_TelegramInput } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TelegramInput";
import dynamic from "next/dynamic";
import {
type ReactElement,
useActionState,
useEffect,
useRef,
useState,
} from "react";
import { useFormStatus } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { createTicketAction } from "./create-ticket.action";
const ConnectSupportForm = dynamic(() => import("./contact-forms/connect"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const EngineSupportForm = dynamic(() => import("./contact-forms/engine"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const ContractSupportForm = dynamic(() => import("./contact-forms/contracts"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const AccountSupportForm = dynamic(() => import("./contact-forms/account"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const OtherSupportForm = dynamic(() => import("./contact-forms/other"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const productOptions: { label: string; component: ReactElement }[] = [
{
component: <ConnectSupportForm />,
label: "Connect",
},
{
component: <EngineSupportForm />,
label: "Engine",
},
{
component: <ContractSupportForm />,
label: "Contracts",
},
{
component: <AccountSupportForm />,
label: "Account",
},
{
component: <OtherSupportForm />,
label: "Other",
},
];
function ProductAreaSelection(props: {
productLabel: string;
setProductLabel: (val: string) => void;
}) {
const { productLabel, setProductLabel } = props;
return (
<div className="flex flex-col gap-6">
<SupportForm_SelectInput
formLabel="What do you need help with?"
name="product"
onValueChange={setProductLabel}
options={productOptions.map((o) => o.label)}
promptText="Select a product"
required={true}
value={productLabel}
/>
{productOptions.find((o) => o.label === productLabel)?.component}
</div>
);
}
export function CreateTicket(props: {
teams: {
name: string;
id: string;
}[];
}) {
const formRef = useRef<HTMLFormElement>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(
props.teams[0]?.id,
);
const [productLabel, setProductLabel] = useState("");
const [state, formAction] = useActionState(createTicketAction, {
message: "",
success: false,
});
// needed here
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!state.message) {
return;
}
if (state.success) {
toast.success(state.message);
} else {
toast.error(state.message);
}
}, [state.success, state.message]);
return (
<form
action={formAction}
className="rounded-lg border bg-card"
ref={formRef}
>
<div className="px-4 py-6 lg:px-6">
<h2 className="font-semibold text-2xl tracking-tight">Get Support</h2>
<p className="text-muted-foreground">
We are here to help. Ask product questions, report problems, or leave
feedback.
</p>
<div className="h-7" />
<div className="flex flex-col gap-6">
{/* Don't conditionally render this - it has be rendered to submit the input values */}
<div className={cn(props.teams.length === 1 && "hidden")}>
<SupportForm_TeamSelection
onChange={(teamId) => setSelectedTeamId(teamId)}
selectedTeamId={selectedTeamId}
teams={props.teams}
/>
</div>
<SupportForm_TelegramInput />
<ProductAreaSelection
productLabel={productLabel}
setProductLabel={setProductLabel}
/>
</div>
</div>
<div className="h-7" />
<div className="flex justify-end gap-3 border-t px-4 py-6 lg:px-6">
<Button
onClick={() => {
formRef.current?.reset();
setProductLabel("");
}}
variant="outline"
>
Reset
</Button>
<SubmitButton />
</div>
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button
className="flex min-w-24 flex-row gap-2"
disabled={pending}
type="submit"
>
{pending && <Spinner className="size-4" />}
{pending ? "Submitting" : "Submit"}
</Button>
);
}
@@ -1,48 +0,0 @@
import Link from "next/link";
import { getTeams } from "@/api/team";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { loginRedirect } from "../../../login/loginRedirect";
import { CreateTicket } from "./components/create-ticket.client";
export default async function Page() {
const teams = await getTeams();
const pagePath = "/support/create-ticket";
if (!teams || teams.length === 0) {
loginRedirect(pagePath);
}
return (
<div>
<Breadcrumb className="border-border border-b px-6 py-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/support">Support</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Get Support</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="container max-w-[750px] py-10">
<CreateTicket
teams={teams.map((t) => ({
id: t.id,
name: t.name,
}))}
/>
</div>
</div>
);
}
@@ -1,12 +0,0 @@
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex grow flex-col">
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
{children}
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

@@ -1,155 +0,0 @@
import {
BookOpenIcon,
ChevronRightIcon,
HomeIcon,
WalletIcon,
} from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getTeams } from "@/api/team";
import { CustomChatButton } from "@/components/chat/CustomChatButton";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EngineIcon } from "@/icons/EngineIcon";
import { InsightIcon } from "@/icons/InsightIcon";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { PayIcon } from "@/icons/PayIcon";
import { siwaExamplePrompts } from "./definitions";
export const metadata: Metadata = {
openGraph: {
title: "thirdweb Support",
},
title: "thirdweb Support",
};
const HELP_PRODUCTS = [
{
description: "Try out our interactive playground to get started",
icon: HomeIcon,
title: "Playground",
viewAllUrl:
"https://playground.thirdweb.com/connect/sign-in/button?tab=code",
},
{
description: "Create and manage crypto wallets",
icon: WalletIcon,
title: "Wallets",
viewAllUrl: "https://portal.thirdweb.com/connect",
},
{
description: "Enable payments on any tokens on any chain",
icon: PayIcon,
title: "Payments",
viewAllUrl: "https://portal.thirdweb.com/pay/troubleshoot",
},
{
description: "Perform read and write transactions onchain",
icon: EngineIcon,
title: "Transactions",
viewAllUrl: "https://portal.thirdweb.com/engine/v3/troubleshoot",
},
{
description: "Query and analyze blockchain data",
icon: InsightIcon,
title: "Insight",
viewAllUrl: "https://portal.thirdweb.com/insight",
},
{
description: "API interface for LLMs",
icon: NebulaIcon,
title: "Nebula",
viewAllUrl: "https://portal.thirdweb.com/nebula",
},
] as const;
export default async function SupportPage() {
const [authToken, accountAddress] = await Promise.all([
getAuthToken(),
getAuthTokenWalletAddress(),
]);
const teams = await getTeams();
const teamId = teams?.[0]?.id ?? undefined;
return (
<main className="flex flex-col gap-12 pb-12">
<div className="bg-gradient-to-b from-card/0 to-card py-20">
<header className="container flex flex-col items-center gap-8">
<div className="rounded-full bg-gradient-to-r from-[#F213A4] to-[#5204BF] p-2">
<div className="rounded-full bg-background p-6 shadow-md">
<BookOpenIcon className="size-8" />
</div>
</div>
<div className="flex max-w-2xl flex-col items-center gap-2">
<h1 className="text-center font-bold text-4xl md:text-balance md:text-5xl">
How can we help?
</h1>
<p className="text-center text-lg text-muted-foreground">
Get instant answers with Nebula AI, our onchain support assistant.
Still need help? You can also create a support case to reach our
team.
</p>
<div className="mt-6 flex w-full flex-col items-center gap-3">
<CustomChatButton
authToken={authToken || undefined}
clientId={undefined}
examplePrompts={siwaExamplePrompts}
isFloating={false}
isLoggedIn={!!accountAddress}
label="Ask AI for support"
networks="all"
pageType="support"
teamId={teamId}
/>
<Link
className="text-muted-foreground text-sm hover:underline"
href="/support/create-ticket"
rel="noopener noreferrer"
target="_blank"
>
Open a support case
</Link>
</div>
</div>
</header>
</div>
<section className="container flex flex-col gap-6">
<h2 className="font-bold text-3xl">Support Articles</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3">
{HELP_PRODUCTS.map((product) => (
<Card className="relative col-span-1 bg-card" key={product.title}>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-2">
{product.icon && <product.icon className="size-5" />}
<CardTitle className="text-xl">{product.title}</CardTitle>
</div>
<Button
asChild
className="absolute top-4 right-2 flex flex-row gap-1 px-2 text-muted-foreground text-sm hover:text-foreground"
size="sm"
variant="ghost"
>
<Link href={product.viewAllUrl} target="_blank">
<span>View</span>
<ChevronRightIcon className="size-4" />
</Link>
</Button>
</CardHeader>
<CardContent key={product.description}>
{product.description && (
<p className="mb-2 text-muted-foreground text-sm">
{product.description}
</p>
)}
</CardContent>
</Card>
))}
</div>
</section>
</main>
);
}
@@ -63,14 +63,14 @@ export const HexConverter = () => {
<Card className="flex flex-col gap-2 p-4">
<p>
Or use the
Convert values using the{" "}
<a
className="underline"
href="https://portal.thirdweb.com/typescript/v5"
rel="noopener noreferrer"
target="_blank"
>
Connect SDK
thirdweb SDK
</a>
:
</p>
@@ -49,14 +49,14 @@ export const Keccak256Converter = () => {
<Card className="flex flex-col gap-2 p-4">
<p>
Or use the{" "}
Convert values using the{" "}
<a
className="underline"
href="https://portal.thirdweb.com/typescript/v5"
rel="noopener noreferrer"
target="_blank"
>
Connect SDK
thirdweb SDK
</a>
:
</p>
@@ -79,14 +79,14 @@ export const WeiConverter = () => {
<Card className="flex flex-col gap-2 p-4">
<p>
Or use the{" "}
Convert values using the{" "}
<a
className="underline"
href="https://portal.thirdweb.com/typescript/v5"
rel="noopener noreferrer"
target="_blank"
>
Connect SDK
thirdweb SDK
</a>
:
</p>
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "@/actions/auth-actions";
import { createTeam } from "@/actions/createTeam";
import { resetAnalytics } from "@/analytics/reset";
import type { Project } from "@/api/projects";
@@ -12,7 +13,6 @@ import { CustomConnectWallet } from "@/components/connect-wallet";
import { LazyCreateProjectDialog } from "@/components/project/create-project-modal/LazyCreateAPIKeyDialog";
import type { Account } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { doLogout } from "../../login/auth-actions";
import {
type AccountHeaderCompProps,
AccountHeaderDesktopUI,
@@ -1,8 +1,8 @@
import {
getProjectContracts,
type ProjectContract,
} from "../../../../../@/api/getProjectContracts";
import { fetchChainWithLocalOverrides } from "../../../../../@/utils/fetchChainWithLocalOverrides";
} from "@/api/getProjectContracts";
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
export async function getSortedDeployedContracts(params: {
onlyMainnet?: boolean;
@@ -1,5 +1,6 @@
import type React from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getProjects } from "@/api/projects";
import { getTeams, type Team } from "@/api/team";
import { AppFooter } from "@/components/footers/app-footer";
@@ -7,12 +8,8 @@ import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
import { TabPathLinks } from "@/components/ui/tabs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import type { Account } from "@/hooks/useApi";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../@/api/auth-token";
import { loginRedirect } from "@/utils/redirects";
import { TWAutoConnect } from "../components/autoconnect";
import { loginRedirect } from "../login/loginRedirect";
import { AccountHeader } from "./components/AccountHeader";
import { getValidAccount } from "./settings/getAccount";
@@ -1,9 +1,9 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeams } from "@/api/team";
import { getMemberByAccountId } from "@/api/team-members";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../@/api/auth-token";
import { loginRedirect } from "../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
import { getValidAccount } from "./settings/getAccount";
@@ -2,13 +2,13 @@
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { upload } from "thirdweb/storage";
import { doLogout } from "@/actions/auth-actions";
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
import { apiServerProxy } from "@/actions/proxies";
import { updateAccount } from "@/actions/updateAccount";
import { resetAnalytics } from "@/analytics/reset";
import type { Account } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { doLogout } from "../../login/auth-actions";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
export function AccountSettingsPage(props: {
@@ -285,7 +285,7 @@ function DeleteAccountCard(props: {
variant="outline"
>
<Link
href="/support/create-ticket"
href="/team/~/~/support"
rel="noopener noreferrer"
target="_blank"
>
@@ -1,8 +1,8 @@
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { Account } from "@/hooks/useApi";
import { getAuthToken } from "../../../../@/api/auth-token";
import { loginRedirect } from "../../login/loginRedirect";
import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
import { loginRedirect } from "@/utils/redirects";
import { isAccountOnboardingComplete } from "../../../login/onboarding/isOnboardingRequired";
/**
* Just get the account object without enforcing onboarding.
@@ -1,7 +1,7 @@
import { getAuthToken } from "@/api/auth-token";
import { getDefaultTeam } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../@/api/auth-token";
import { loginRedirect } from "../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { AccountSettingsPage } from "./AccountSettingsPage";
import { getValidAccount } from "./getAccount";
@@ -1,7 +1,7 @@
import { getAuthToken } from "@/api/auth-token";
import { getLinkedWallets } from "@/api/linked-wallets";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../@/api/auth-token";
import { loginRedirect } from "../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { getValidAccount } from "../settings/getAccount";
import { LinkWallet } from "./LinkWalletUI";
@@ -44,15 +44,6 @@ export function SecondaryNavLinks() {
Docs
</Link>
<Link
className="text-muted-foreground text-sm hover:text-foreground"
href="/support"
rel="noopener noreferrer"
target="_blank"
>
Support
</Link>
<Link
className="text-muted-foreground text-sm hover:text-foreground"
href="https://feedback.thirdweb.com"
@@ -191,15 +191,6 @@ export function MobileBurgerMenuButton(
Docs
</Link>
<Link
className="text-muted-foreground hover:text-foreground "
href="/support"
rel="noopener noreferrer"
target="_blank"
>
Support
</Link>
<Link
className="text-muted-foreground hover:text-foreground"
href="https://feedback.thirdweb.com"
@@ -4,7 +4,10 @@ function prefixId(id: string, prefix?: string) {
return prefix ? `${prefix}_${id}` : id;
}
export function ThirdwebMiniLogo(props: { className?: string }) {
export function ThirdwebMiniLogo(props: {
className?: string;
isMonoChrome?: boolean;
}) {
const id = useId();
return (
<svg className={props.className} fill="none" viewBox="0 0 516 321">
@@ -12,15 +15,27 @@ export function ThirdwebMiniLogo(props: { className?: string }) {
<g clipPath={`url(#${prefixId("clip0_3:35)", id)}`}>
<path
d="M1.40497 27.0011C-3.73633 14.022 5.84519 0 19.8669 0H106.919C115.098 0 122.342 4.86715 125.381 12.3996L194.671 185.299C196.541 189.935 196.541 195.149 194.671 199.901L151.087 308.484C144.427 325.056 120.823 325.056 114.163 308.484L1.40497 27.0011Z"
fill={`url(#${prefixId("paint0_linear_3:35)", id)}`}
fill={
props.isMonoChrome
? "currentColor"
: `url(#${prefixId("paint0_linear_3:35)", id)}`
}
/>
<path
d="M169.547 26.4217C164.873 13.5585 174.454 0 188.242 0H264.077C272.49 0 279.968 5.2148 282.772 12.9791L345.753 185.879C347.272 190.166 347.272 194.918 345.753 199.321L307.894 303.27C301.585 320.652 276.813 320.652 270.503 303.27L169.547 26.4217Z"
fill={`url(#${prefixId("paint1_linear_3:35)", id)}`}
fill={
props.isMonoChrome
? "currentColor"
: `url(#${prefixId("paint1_linear_3:35)", id)}`
}
/>
<path
d="M321.331 27.0011C316.19 14.022 325.771 0 339.793 0H426.845C435.024 0 442.269 4.86715 445.307 12.3996L514.597 185.299C516.467 189.935 516.467 195.149 514.597 199.901L471.013 308.484C464.353 325.056 440.75 325.056 434.089 308.484L321.331 27.0011Z"
fill={`url(#${prefixId("paint2_linear_3:35)", id)}`}
fill={
props.isMonoChrome
? "currentColor"
: `url(#${prefixId("paint2_linear_3:35)", id)}`
}
/>
</g>
<defs>
@@ -1,9 +1,9 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../../../@/api/auth-token";
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
import { InviteTeamMembers } from "../../../../login/onboarding/team-onboarding/team-onboarding";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { InviteTeamMembers } from "../../../../../login/onboarding/team-onboarding/team-onboarding";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
@@ -1,17 +1,14 @@
import { differenceInDays } from "date-fns";
import { InfoIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getProjects } from "@/api/projects";
import { getTeamBySlug, getTeams } from "@/api/team";
import { AppFooter } from "@/components/footers/app-footer";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../../../@/api/auth-token";
import { loginRedirect } from "@/utils/redirects";
import { getValidAccount } from "../../../account/settings/getAccount";
import { loginRedirect } from "../../../login/loginRedirect";
import { TeamHeaderLoggedIn } from "../../../team/components/TeamHeader/team-header-logged-in.client";
export default async function Layout(props: {
@@ -1,10 +1,10 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../../@/api/auth-token";
import { loginRedirect } from "../../../login/loginRedirect";
import { TeamOnboardingLayout } from "../../../login/onboarding/onboarding-layout";
import { TeamInfoForm } from "../../../login/onboarding/team-onboarding/team-onboarding";
import { loginRedirect } from "@/utils/redirects";
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
import { TeamInfoForm } from "../../../../login/onboarding/team-onboarding/team-onboarding";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug, type Team } from "@/api/team";
import { getAuthToken } from "../../../../../../@/api/auth-token";
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { PlanSelector } from "./_components/plan-selector";
export default async function Page(props: {
@@ -2,7 +2,6 @@
import { useMutation } from "@tanstack/react-query";
import { CheckIcon, UsersIcon } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { acceptInvite } from "@/actions/acceptInvite";
import type { Team } from "@/api/team";
@@ -65,19 +64,7 @@ function Header() {
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Link
className="px-2 text-muted-foreground text-sm hover:text-foreground"
href="/support"
rel="noopener noreferrer"
target="_blank"
>
Support
</Link>
</div>
<ToggleThemeButton />
</div>
<ToggleThemeButton />
</header>
</div>
);
+1 -1
View File
@@ -54,7 +54,7 @@ export default function RootLayout({
<body
className={cn("bg-background font-sans antialiased", fontSans.variable)}
>
<AppRouterProviders>{children}</AppRouterProviders>
<AppRouterProviders autoConnect={true}>{children}</AppRouterProviders>
<DashboardRouterTopProgressBar />
<NextTopLoader
color="hsl(var(--foreground))"
+8 -10
View File
@@ -1,16 +1,16 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider, useTheme } from "next-themes";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { useEffect, useMemo } from "react";
import { Toaster } from "sonner";
import {
ThirdwebProvider,
useActiveAccount,
useConnectionManager,
} from "thirdweb/react";
import { CustomConnectWallet } from "@/components/connect-wallet";
import { Toaster } from "@/components/ui/sonner";
import { isSanctionedAddress } from "@/constants/eth-sanctioned-addresses";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { useAllChainsData } from "@/hooks/chains/allChains";
@@ -20,21 +20,24 @@ import { TWAutoConnect } from "./components/autoconnect";
const queryClient = new QueryClient();
const thirdwebClient = getClientThirdwebClient();
export function AppRouterProviders(props: { children: React.ReactNode }) {
export function AppRouterProviders(props: {
children: React.ReactNode;
autoConnect: boolean;
}) {
return (
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<SyncChainStores />
<ThirdwebProvider>
<SyncChainDefinitionsToConnectionManager />
<TWAutoConnect client={thirdwebClient} />
{props.autoConnect && <TWAutoConnect client={thirdwebClient} />}
<ThemeProvider
attribute="class"
defaultTheme="dark"
disableTransitionOnChange
enableSystem={false}
>
<ToasterSetup />
<Toaster richColors />
<SanctionedAddressesChecker>
{props.children}
</SanctionedAddressesChecker>
@@ -45,11 +48,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
);
}
function ToasterSetup() {
const { theme } = useTheme();
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
}
function SyncChainDefinitionsToConnectionManager() {
const { allChainsV5 } = useAllChainsData();
const connectionManager = useConnectionManager();
@@ -6,6 +6,7 @@ import {
DatabaseIcon,
DollarSignIcon,
FileTextIcon,
HelpCircleIcon,
HomeIcon,
SettingsIcon,
WalletCardsIcon,
@@ -89,6 +90,11 @@ export function TeamSidebarLayout(props: {
: []),
]}
footerSidebarLinks={[
{
href: `${layoutPath}/~/support`,
icon: HelpCircleIcon,
label: "Support",
},
{
href: `${layoutPath}/~/billing`,
icon: DollarSignIcon,
@@ -7,9 +7,9 @@ import { getChainSubscriptions } from "@/api/team-subscription";
import { CustomChatButton } from "@/components/chat/CustomChatButton";
import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { siwaExamplePrompts } from "@/constants/siwa-example-prompts";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getChain } from "../../../(dashboard)/(chain)/utils";
import { siwaExamplePrompts } from "../../../(dashboard)/support/definitions";
import { getValidAccount } from "../../../account/settings/getAccount";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
import { StaffModeNotice } from "./_components/StaffModeNotice";
@@ -103,17 +103,13 @@ export default async function TeamLayout(props: {
>
{props.children}
</TeamSidebarLayout>
<div className="fixed right-6 bottom-6 z-50">
<div className="fixed right-4 bottom-4 z-50">
<CustomChatButton
authToken={authToken}
clientId={undefined}
examplePrompts={siwaExamplePrompts}
isFloating={true}
isLoggedIn={true}
label="Ask AI Assistant"
networks="all"
pageType="support"
teamId={team.id}
label="Get Help"
team={team}
/>
</div>
</div>
@@ -1,12 +1,12 @@
import { subDays } from "date-fns";
import { redirect } from "next/navigation";
import { getWalletConnections } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProjects, type Project } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../../@/api/auth-token";
import { loginRedirect } from "../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { Changelog } from "./_components/Changelog";
import { FreePlanUpsellBannerUI } from "./_components/FreePlanUpsellBannerUI";
import { InviteTeamMembersButton } from "./_components/invite-team-members-button";
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { TabPathLinks } from "@/components/ui/tabs";
import { loginRedirect } from "../../../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
params: Promise<{
@@ -3,7 +3,7 @@ import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { AddPartnerForm } from "../components/client/add-partner-form.client";
export default async function AddPartnerPage({
@@ -3,7 +3,7 @@ import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { UpdatePartnerForm } from "../../../components/client/update-partner-form.client";
import { fetchPartnerDetails } from "../../../hooks/fetchPartnerDetails";
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { getAuthToken } from "../../../../../../../../../@/api/auth-token";
import { loginRedirect } from "../../../../../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { EcosystemPermissionsPage } from "./configuration/components/client/EcosystemPermissionsPage";
export default async function Page(props: {
@@ -1,10 +1,10 @@
import { loginRedirect } from "@app/login/loginRedirect";
import { InAppWalletUsersPageContent } from "@app/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
export default async function EcosystemUsersPage(props: {
params: Promise<{ team_slug: string; slug: string }>;
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystemList } from "@/api/ecosystems";
import { loginRedirect } from "../../../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
@@ -5,9 +5,9 @@ import { formatToDollars } from "@/components/billing/formatToDollars";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { ChainIconClient } from "@/icons/ChainIcon";
import { ToolTipLabel } from "../../../../../../../../@/components/ui/tooltip";
import { getChain } from "../../../../../../(dashboard)/(chain)/utils";
import { InfraServiceCard } from "./_components/service-card";
@@ -1,8 +1,8 @@
"use server";
import { getAuthToken } from "@/api/auth-token";
import type { Team } from "@/api/team";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken } from "../../../../../../../../@/api/auth-token";
export async function updateTeam(params: {
teamId: string;
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { TabPathLinks } from "@/components/ui/tabs";
import { loginRedirect } from "../../../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
params: Promise<{
@@ -4,7 +4,7 @@ import type { TeamAccountRole } from "@/api/team-members";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { teamStub } from "@/storybook/stubs";
import { storybookThirdwebClient } from "../../../../../../../../@/storybook/utils";
import { storybookThirdwebClient } from "@/storybook/utils";
import { InviteSection } from "./InviteSection";
const meta = {
@@ -37,8 +37,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { getValidTeamPlan } from "../../../../../../../../@/utils/getValidTeamPlan";
const inviteFormSchema = z.object({
invites: z
@@ -1,9 +1,9 @@
import { notFound, redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { getTeamInvites } from "@/api/team-invites";
import { getMembers } from "@/api/team-members";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../../../../../@/api/auth-token";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { getRecommendedMembers } from "./getRecommendedMembers";
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
@@ -1,9 +1,9 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { getMemberByAccountId } from "@/api/team-members";
import { checkDomainVerification } from "@/api/verified-domain";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../../../../../@/api/auth-token";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { TeamGeneralSettingsPage } from "./general/TeamGeneralSettingsPage";
@@ -0,0 +1,328 @@
"use client";
import { ArrowRightIcon, UserIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useState } from "react";
import type { Team } from "@/api/team";
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TextShimmer } from "@/components/ui/text-shimmer";
import { AutoResizeTextarea } from "@/components/ui/textarea";
import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo";
import { SupportTicketForm } from "./SupportTicketForm";
export function CreateSupportCase(props: { team: Team; authToken: string }) {
const { team, authToken } = props;
const [chatMessages, setChatMessages] = useState<
{
id: number;
content: string;
isUser: boolean;
timestamp: string;
isSuccessMessage?: boolean;
}[]
>([
{
content:
"Hi! I'm thirdweb's AI assistant. I can help you troubleshoot issues, answer questions about our products, or create a support case for you. What can I help you with today?",
id: Date.now(),
isUser: false,
timestamp: new Date().toISOString(),
},
]);
const [chatInput, setChatInput] = useState("");
const [conversationId, setConversationId] = useState<string | undefined>(
undefined,
);
// Form states
const [showCreateForm, setShowCreateForm] = useState(false);
const [productLabel, setProductLabel] = useState("");
// Extracted sendMessageToSiwa function to avoid duplication
const sendMessageToSiwa = useCallback(
async (message: string, currentConversationId?: string) => {
if (!authToken) {
throw new Error("Authentication token is required");
}
const apiUrl = process.env.NEXT_PUBLIC_SIWA_URL;
const payload = {
conversationId: currentConversationId,
message,
source: "support-in-dashboard",
};
const response = await fetch(`${apiUrl}/v1/chat`, {
body: JSON.stringify(payload),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-team-id": team.id,
},
method: "POST",
});
const data = await response.json();
if (
data.conversationId &&
data.conversationId !== currentConversationId
) {
setConversationId(data.conversationId);
}
return data.data;
},
[authToken, team.id],
);
const handleChatSend = useCallback(async () => {
if (!chatInput.trim()) return;
const currentInput = chatInput;
setChatInput("");
const userMsg = {
content: currentInput,
id: Date.now(),
isUser: true,
timestamp: new Date().toISOString(),
};
const loadingMsg = {
content: "__reasoning__",
id: Date.now() + 1,
isUser: false,
timestamp: new Date().toISOString(),
};
setChatMessages((msgs) => [...msgs, userMsg, loadingMsg]);
try {
const aiResponse = await sendMessageToSiwa(currentInput, conversationId);
setChatMessages((msgs) => [
...msgs.slice(0, -1), // remove loading
{
content: aiResponse,
id: Date.now() + 2,
isUser: false,
timestamp: new Date().toISOString(),
},
]);
} catch (error) {
console.error("Error in handleChatSend:", error);
setChatMessages((msgs) => [
...msgs.slice(0, -1),
{
content: "Sorry, something went wrong. Please try again.",
id: Date.now() + 3,
isUser: false,
timestamp: new Date().toISOString(),
},
]);
}
}, [chatInput, conversationId, sendMessageToSiwa]);
const handleChatKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleChatSend();
}
},
[handleChatSend],
);
const aiIcon = (
<div className="rounded-full size-9 border shrink-0 flex items-center justify-center bg-inverted">
<ThirdwebMiniLogo
className="size-4 text-inverted-foreground"
isMonoChrome
/>
</div>
);
const userIcon = (
<div className="rounded-full size-9 border bg-muted/50 shrink-0 flex items-center justify-center">
<UserIcon className="size-4 text-muted-foreground" />
</div>
);
const [showCreateCaseDialog, setShowCreateCaseDialog] = useState(false);
return (
<div className="flex flex-col border bg-card rounded-xl mt-4">
<div className="px-4 lg:px-12 py-4 lg:py-10 border-b border-dashed">
<h2 className="text-lg lg:text-2xl font-semibold tracking-tight mb-1 md:mb-0.5 leading-none">
Chat with support
</h2>
<p className="text-muted-foreground text-sm md:text-base text-balance">
Describe your issue and we'll help you resolve it
</p>
</div>
{/* Chat Messages */}
<DynamicHeight>
<div className="flex-1 overflow-y-auto space-y-8 px-4 lg:px-12 pt-8 pb-20">
{chatMessages.map((message, index) => (
<div key={message.id}>
{message.isUser ? (
<div className="flex items-start gap-3.5">
{userIcon}
<div className="px-3.5 py-2 rounded-xl border bg-muted/50 relative">
<StyledMarkdownRenderer
text={message.content}
type="user"
isMessagePending={false}
/>
</div>
</div>
) : message.content === "__reasoning__" ? (
<div className="flex items-center gap-3.5">
{aiIcon}
<TextShimmer
text="Reasoning..."
className="text-sm md:text-base"
/>
</div>
) : (
<div className="flex items-start gap-3.5">
{aiIcon}
<div>
<StyledMarkdownRenderer
text={message.content}
type="assistant"
isMessagePending={false}
/>
{/* Show Create Support Case button in the AI response - only if form not shown and after user interaction */}
{index === chatMessages.length - 1 &&
!message.isSuccessMessage &&
chatMessages.length > 2 && (
<div className="mt-5">
<Dialog
open={showCreateCaseDialog}
onOpenChange={setShowCreateCaseDialog}
>
<Button
size="sm"
variant="outline"
className="gap-2 rounded-full bg-background"
onClick={() => setShowCreateCaseDialog(true)}
>
Create Support Case
<ArrowRightIcon className="w-4 h-4" />
</Button>
<DialogContent className="p-0 bg-card !gap-0">
<DynamicHeight>
<DialogHeader className="p-4 lg:p-6 border-b border-dashed space-y-1">
<DialogTitle className="text-xl font-semibold text-foreground tracking-tight">
Create Support Case
</DialogTitle>
<p className="text-muted-foreground text-sm">
Let's create a detailed support case for our
technical team.
</p>
</DialogHeader>
<SupportTicketForm
closeForm={() => {
setShowCreateCaseDialog(false);
}}
team={team}
productLabel={productLabel}
setProductLabel={setProductLabel}
conversationId={conversationId}
onSuccess={() => {
setShowCreateForm(false);
setProductLabel("");
setChatMessages((prev) => [
...prev,
{
id: Date.now(),
content: `Support case created successfully!\n\nYour case has been submitted to our technical team. You'll receive updates via email at ${team.billingEmail}.\n\nYou can track your case in the support portal above.`,
isUser: false,
timestamp: new Date().toISOString(),
isSuccessMessage: true,
},
]);
}}
/>
</DynamicHeight>
</DialogContent>
</Dialog>
</div>
)}
{/* Show Back to Support button for success message */}
{!message.isUser && message.isSuccessMessage && (
<div className="mt-5">
<Button asChild size="sm" className="gap-2">
<Link href={`/team/${team.slug}/~/support`}>
View all cases
<ArrowRightIcon className="w-4 h-4" />
</Link>
</Button>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
</DynamicHeight>
{/* Chat Input */}
{!showCreateForm && (
<div className="px-4 lg:px-12 pb-4 lg:pb-12">
<div className="relative">
<AutoResizeTextarea
placeholder="I am having an issue with..."
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={handleChatKeyPress}
className="min-h-[120px] rounded-xl"
/>
<Button
onClick={handleChatSend}
variant="default"
size="sm"
disabled={!chatInput.trim()}
className="absolute bottom-3 right-3 disabled:opacity-100"
>
Send
</Button>
</div>
</div>
)}
</div>
);
}
function StyledMarkdownRenderer(props: {
text: string;
isMessagePending: boolean;
type: "assistant" | "user";
}) {
return (
<MarkdownRenderer
className="text-sm md:text-base text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0 leading-relaxed"
code={{
className: "bg-transparent",
ignoreFormattingErrors: true,
}}
inlineCode={{ className: "border-none" }}
li={{ className: "text-foreground leading-relaxed" }}
markdownText={props.text}
p={{
className: "text-foreground leading-relaxed",
}}
skipHtml
/>
);
}
@@ -0,0 +1,307 @@
"use client";
import { format } from "date-fns";
import { ChevronDownIcon, UserIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { sendMessageToTicket } from "@/api/support";
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { AutoResizeTextarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo";
import type { SupportMessage, SupportTicket } from "../types/tickets";
import {
getTicketStatusBadgeVariant,
getTicketStatusLabel,
} from "../utils/ticket-status";
interface SupportCaseDetailsProps {
ticket: SupportTicket;
team: Team;
}
export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
const [replyMessage, setReplyMessage] = useState("");
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [localMessages, setLocalMessages] = useState(ticket.messages || []);
const handleSendReply = async () => {
if (!team.unthreadCustomerId) {
toast.error("No unthread customer id found for this team");
return;
}
if (!team.billingEmail) {
toast.error("No Billing email found for this team");
return;
}
if (!replyMessage.trim()) {
toast.error("Please enter a message");
return;
}
setIsSubmittingReply(true);
// Optimistically add the message to the UI immediately
const optimisticMessage = {
id: `optimistic-${crypto.randomUUID()}`,
content: replyMessage,
createdAt: new Date().toISOString(),
timestamp: new Date().toISOString(),
author: {
name: "You",
email: team.billingEmail || "",
type: "customer" as const,
},
};
setLocalMessages((prev) => [...prev, optimisticMessage]);
setReplyMessage("");
try {
const result = await sendMessageToTicket({
message: replyMessage,
teamSlug: team.slug,
teamId: team.id,
ticketId: ticket.id,
});
if ("error" in result) {
throw new Error(result.error);
}
setReplyMessage("");
} catch (error) {
console.error("Failed to send reply:", error);
toast.error("Failed to send Message. Please try again.");
// Remove the optimistic message on error
setLocalMessages((prev) =>
prev.filter((msg) => msg.id !== optimisticMessage.id),
);
setReplyMessage(replyMessage); // Restore the message
} finally {
setIsSubmittingReply(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (e.shiftKey) {
return;
}
e.preventDefault();
handleSendReply();
}
};
return (
<div className="flex flex-col grow">
<div className="h-3" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/team/${team.slug}/~/support`}>Cases</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbList>
</Breadcrumb>
<div className="h-3" />
<TicketHeader
title={ticket.title}
createdAt={ticket.createdAt}
updatedAt={ticket.updatedAt}
status={ticket.status}
/>
<div className="h-6" />
<div className="border bg-card rounded-lg">
<div>
{localMessages && localMessages.length > 0 ? (
localMessages.map((message) => {
return <TicketMessage key={message.id} message={message} />;
})
) : (
<div className="flex items-center justify-center py-16">
<div className="text-muted-foreground text-sm">
No Messages Found
</div>
</div>
)}
</div>
{ticket.status === "closed" && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
This ticket is closed. If you need further assistance, please
create a new ticket.
</p>
</div>
)}
</div>
<div className="h-8" />
{/* Reply Section */}
{ticket.status !== "closed" && ticket.status !== "resolved" && (
<div>
<div className="relative">
<AutoResizeTextarea
onChange={(e) => setReplyMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Your message..."
value={replyMessage}
className="min-h-[120px] bg-card leading-relaxed rounded-lg"
/>
<Button
className="absolute bottom-3 right-3 gap-2 disabled:opacity-100 rounded-lg h-auto py-1.5 px-3"
size="sm"
disabled={!replyMessage.trim() || isSubmittingReply}
onClick={handleSendReply}
variant="default"
>
{isSubmittingReply ? <Spinner className="size-4" /> : "Send"}
</Button>
</div>
</div>
)}
</div>
);
}
function TicketHeader(props: {
title: string;
createdAt: string;
updatedAt: string;
status: SupportTicket["status"];
}) {
return (
<div className="flex flex-col md:flex-row justify-between items-start gap-4">
<div>
<h2 className="text-2xl font-semibold text-foreground md:mb-1 mb-3 tracking-tight leading-tight">
{props.title}
</h2>
<div className="flex-col md:flex-row flex lg:items-center gap-1 md:gap-2.5">
<p className="text-muted-foreground text-sm">
Opened on {format(new Date(props.createdAt), "MMM d, yyyy")}
</p>
<div className="size-1 bg-muted-foreground rounded-full hidden md:block" />
<p className="text-muted-foreground text-sm">
Last updated on {format(new Date(props.updatedAt), "MMM d, yyyy")}
</p>
</div>
</div>
<Badge
variant={getTicketStatusBadgeVariant(props.status)}
className="text-sm px-3 py-1.5"
>
{getTicketStatusLabel(props.status)}
</Badge>
</div>
);
}
function TicketMessage(props: { message: SupportMessage }) {
const { message } = props;
const [isExpanded, setIsExpanded] = useState(true);
const isCustomer = message.author?.type === "customer";
const displayName = isCustomer ? "You" : "thirdweb Support";
let messageContent = message.content || "No content available";
messageContent = messageContent
.replaceAll("/unthread send", "")
.trim()
// make sure there are no more than 2 new lines in a row
.replace(/\n{2,}/g, "\n\n");
// cut off anything after "AI Conversation ID"
messageContent = messageContent.split("---\nAI Conversation ID")[0] || "";
return (
<div
className="border-b flex gap-3 p-4 lg:p-6 last:border-b-0"
key={message.id}
>
{/* left - icon */}
<div
className={cn(
"rounded-full size-8 border flex items-center justify-center shrink-0 translate-y-1",
isCustomer ? "bg-muted" : "bg-inverted",
)}
>
{isCustomer ? (
<UserIcon className="size-4" />
) : (
<ThirdwebMiniLogo
className="size-5 text-inverted-foreground"
isMonoChrome
/>
)}
</div>
{/* right */}
<div className="min-w-0 flex-1">
{/* User, Timestamp */}
<div className="flex items-center gap-3 justify-between">
<div className="flex flex-col gap-0.5">
<span className="font-medium text-foreground text-sm">
{displayName}
</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{format(new Date(message.timestamp), "MMM d, yyyy 'at' h:mm a")}
</span>
</div>
<Button
variant="ghost"
className="size-10 rounded-full p-0 translate-x-2"
onClick={() => setIsExpanded(!isExpanded)}
>
<ChevronDownIcon
className={cn(
"size-5 text-muted-foreground transition-transform",
isExpanded ? "rotate-180" : "",
)}
/>
</Button>
</div>
{/* message */}
<DynamicHeight>
<div>
{isExpanded ? (
<div className="text-sm text-foreground break-words overflow-auto whitespace-pre-wrap leading-relaxed pt-4">
{messageContent}
</div>
) : (
<div className="h-[1px]" />
)}
</div>
</DynamicHeight>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { Button } from "@/components/ui/button";
export function SupportHeader(props: {
teamSlug: string;
isFreePlan: boolean;
}) {
const layoutSegment = useSelectedLayoutSegment();
return (
<div className="border-border border-b">
<div className="container flex max-w-5xl flex-col items-start gap-3 py-9 md:flex-row md:items-center">
<div className="flex-1">
<h1 className="font-semibold text-3xl tracking-tight mb-1">
Support Center
</h1>
<p className="text-sm text-muted-foreground">
Create and view support cases for your projects
</p>
</div>
{!props.isFreePlan &&
(layoutSegment === null ? (
<Button asChild className="rounded-full gap-2">
<Link href={`/team/${props.teamSlug}/~/support/create`}>
Create Case
</Link>
</Button>
) : (
<Button
asChild
className="rounded-full gap-2 bg-card"
variant="outline"
>
<Link href={`/team/${props.teamSlug}/~/support`}>
View All Cases
</Link>
</Button>
))}
</div>
</div>
);
}
@@ -0,0 +1,255 @@
import { LockIcon } from "lucide-react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { revalidatePathAction } from "@/actions/revalidate";
import { createSupportTicket } from "@/api/support";
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { SupportForm_SelectInput } from "./shared/SupportForm_SelectInput";
// Dynamic imports for contact forms using named exports
const ConnectSupportForm = dynamic(
() => import("./contact-forms/connect").then((mod) => mod.ConnectSupportForm),
{
loading: () => <Skeleton className="h-12" />,
ssr: false,
},
);
const EngineSupportForm = dynamic(
() => import("./contact-forms/engine").then((mod) => mod.EngineSupportForm),
{
loading: () => <Skeleton className="h-12" />,
ssr: false,
},
);
const ContractSupportForm = dynamic(
() =>
import("./contact-forms/contracts").then((mod) => mod.ContractSupportForm),
{
loading: () => <Skeleton className="h-12" />,
ssr: false,
},
);
const AccountSupportForm = dynamic(
() => import("./contact-forms/account").then((mod) => mod.AccountSupportForm),
{
loading: () => <Skeleton className="h-12" />,
ssr: false,
},
);
const OtherSupportForm = dynamic(
() => import("./contact-forms/other").then((mod) => mod.OtherSupportForm),
{
loading: () => <Skeleton className="h-12" />,
ssr: false,
},
);
const PaymentsSupportForm = dynamic(
() =>
import("./contact-forms/payments").then((mod) => mod.PaymentsSupportForm),
{
loading: () => <Skeleton className="h-12" />,
ssr: false,
},
);
const TokensMarketplaceSupportForm = dynamic(
() =>
import("./contact-forms/tokens-marketplace").then(
(mod) => mod.TokensMarketplaceSupportForm,
),
{
loading: () => <Skeleton className="h-12" />,
ssr: false,
},
);
const productOptions = [
{ component: <ConnectSupportForm />, label: "Wallets" },
{ component: <EngineSupportForm />, label: "Transactions" },
{ component: <PaymentsSupportForm />, label: "Payments" },
{ component: <ContractSupportForm />, label: "Contracts" },
{
component: <TokensMarketplaceSupportForm />,
label: "Tokens / Marketplace",
},
{ component: <AccountSupportForm />, label: "Account" },
{ component: <OtherSupportForm />, label: "Other" },
];
function ProductAreaSelection(props: {
productLabel: string;
setProductLabel: (val: string) => void;
}) {
const { productLabel, setProductLabel } = props;
return (
<div className="flex flex-col gap-6">
<SupportForm_SelectInput
formLabel="What do you need help with?"
name="product"
onValueChange={setProductLabel}
options={productOptions.map((o) => o.label)}
promptText="Brief description of your issue"
required={true}
value={productLabel}
/>
{productOptions.find((o) => o.label === productLabel)?.component}
</div>
);
}
interface SupportTicketFormProps {
team: Team;
productLabel: string;
setProductLabel: (val: string) => void;
onSuccess?: () => void;
conversationId?: string;
closeForm: () => void;
}
export function SupportTicketForm({
team,
productLabel,
setProductLabel,
onSuccess,
conversationId,
closeForm,
}: SupportTicketFormProps) {
const [isSubmittingForm, setIsSubmittingForm] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!productLabel) {
toast.error("Please select what you need help with");
return;
}
if (!formRef.current) return;
const formData = new FormData(formRef.current);
const description = formData.get("markdown") as string;
if (!description?.trim()) {
toast.error("Please provide a description");
return;
}
setIsSubmittingForm(true);
try {
// Get all extra fields from the form
const extraFields = Array.from(formData.entries()).filter(([key]) =>
key.startsWith("extraInfo_"),
);
// Format the message
let formattedMessage = `Email: ${String(team.billingEmail ?? "-")}`;
formattedMessage += `\nName: ${String(team.name ?? "-")}`;
formattedMessage += `\nProduct: ${String(productLabel ?? "-")}`;
// Add all extra fields above the message
if (extraFields.length > 0) {
extraFields.forEach(([key, value]) => {
if (value) {
const fieldName = key.replace("extraInfo_", "").replace(/_/g, " ");
formattedMessage += `\n${fieldName}: ${String(value)}`;
}
});
}
formattedMessage += `\nMessage:\n${String(description ?? "-")}`;
if (conversationId) {
formattedMessage += `\n\n---\nAI Conversation ID: ${conversationId}`;
}
const result = await createSupportTicket({
message: formattedMessage,
teamSlug: team.slug,
teamId: team.id,
title: `${productLabel} Issue - ${team.billingEmail} (${team.billingPlan})`,
conversationId: conversationId,
});
if ("error" in result) {
throw new Error(result.error);
}
// Revalidate the support page to show the newly created case
await revalidatePathAction(`/team/${team.slug}/support`, "page");
if (onSuccess) onSuccess();
if (formRef.current) formRef.current.reset();
setProductLabel("");
toast.success("Support ticket created successfully!");
} catch (error) {
console.error("Error creating support ticket:", error);
toast.error("Failed to create support ticket. Please try again.");
} finally {
setIsSubmittingForm(false);
}
};
if (team.billingPlan === "free") {
return (
<div className="relative">
<div className="px-4 lg:px-6 py-16 z-10 relative">
<div className="flex justify-center">
<div className="mb-4 border rounded-full p-3 bg-background">
<LockIcon className="size-5 text-foreground" />
</div>
</div>
<p className="text-sm text-foreground text-center mb-4">
You are currently on the free plan. <br /> Please upgrade to a paid
plan to create a support case.
</p>
<div className="flex justify-center">
<Button asChild size="sm">
<Link
href={`/team/${team.slug}/~/billing?showPlans=true`}
onClick={() => {
closeForm();
}}
>
Upgrade Plan
</Link>
</Button>
</div>
</div>
</div>
);
}
return (
<form onSubmit={handleFormSubmit} ref={formRef}>
<div className="p-4 lg:p-6 max-h-[60vh] overflow-y-auto">
<ProductAreaSelection
productLabel={productLabel}
setProductLabel={setProductLabel}
/>
</div>
{/* Submit Buttons */}
<div className="flex justify-end gap-3 p-4 lg:p-6 border-t border-dashed">
<Button
type="submit"
disabled={isSubmittingForm}
className="gap-2 disabled:opacity-100"
>
{isSubmittingForm ? (
<>
<Spinner className="size-4" />
Creating Support Case
</>
) : (
"Create Support Case"
)}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,57 @@
import { SearchIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export function SupportCaseFilters(props: {
activeTab: string;
onTabChange: (tab: string) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
counts: {
all: number;
open: number;
closed: number;
};
}) {
const { activeTab, onTabChange, searchQuery, onSearchChange, counts } = props;
const tabs = [
{ count: counts.all, id: "all", label: "All" },
{ count: counts.open, id: "open", label: "Open" },
{ count: counts.closed, id: "closed", label: "Closed" },
];
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Search Bar */}
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
className="pl-9 bg-card rounded-lg"
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search"
value={searchQuery}
/>
</div>
{/* Tab Buttons */}
<div className="grid w-full grid-cols-3 border bg-card p-1 rounded-lg sm:w-fit gap-0.5">
{tabs.map((tab) => (
<Button
className={cn(
"h-8 px-2 py-1 text-sm transition-colors rounded-sm sm:px-3",
activeTab === tab.id
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/70",
)}
key={tab.id}
onClick={() => onTabChange(tab.id)}
variant="ghost"
>
<span>{tab.label}</span>
</Button>
))}
</div>
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { formatDate, formatDistanceToNow } from "date-fns";
import { XIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import type { SupportTicketListItem } from "../types/tickets";
import {
getTicketStatusBadgeVariant,
getTicketStatusLabel,
} from "../utils/ticket-status";
import { SupportCaseFilters } from "./case-filters";
function isTicketOpen(ticket: SupportTicketListItem) {
return (
ticket.status === "in_progress" ||
ticket.status === "needs_response" ||
ticket.status === "on_hold"
);
}
function isTicketClosed(ticket: SupportTicketListItem) {
return ticket.status === "resolved" || ticket.status === "closed";
}
export function SupportsCaseList({
tickets,
team,
}: {
tickets: SupportTicketListItem[];
team: Team;
}) {
const [activeTab, setActiveTab] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
// Filter tickets based on active tab and search query
const filteredTickets = tickets.filter((ticket) => {
// Filter by tab
let matchesTab = true;
switch (activeTab) {
case "open":
matchesTab = isTicketOpen(ticket);
break;
case "closed":
matchesTab = isTicketClosed(ticket);
break;
default:
matchesTab = true;
}
// Filter by search query
const matchesSearch =
searchQuery === "" ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase());
return matchesTab && matchesSearch;
});
// Calculate counts for tabs using inline search logic
const counts = {
all: tickets.filter(
(ticket) =>
searchQuery === "" ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()),
).length,
closed: tickets.filter(
(ticket) =>
isTicketClosed(ticket) &&
(searchQuery === "" ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase())),
).length,
open: tickets.filter(
(ticket) =>
isTicketOpen(ticket) &&
(searchQuery === "" ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase())),
).length,
};
return (
<div className="flex flex-col">
<SupportCaseFilters
activeTab={activeTab}
counts={counts}
onSearchChange={setSearchQuery}
onTabChange={setActiveTab}
searchQuery={searchQuery}
/>
<div className="h-4" />
{filteredTickets.length === 0 ? (
<div className="flex min-h-[300px] items-center justify-center rounded-lg border border-border bg-card">
<div className="text-center">
<div className="flex items-center justify-center mb-3">
<div className="border p-1 rounded-full">
<XIcon className="size-5 text-muted-foreground" />
</div>
</div>
<p className="text-muted-foreground text-sm">
{activeTab === "all"
? "No Support Cases"
: `No ${activeTab} cases found`}
</p>
</div>
</div>
) : (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead className="w-[150px]">Status</TableHead>
<TableHead className="w-[200px]">Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTickets.map((ticket) => (
<TableRow
key={ticket.title}
linkBox
className="hover:bg-accent/50 cursor-pointer"
>
<TableCell className="py-5">
<Link
className="text-foreground font-medium text-sm before:absolute before:inset-0"
href={`/team/${team.slug}/~/support/cases/${ticket.id}`}
>
{ticket.title}
</Link>
</TableCell>
<TableCell>
<Badge variant={getTicketStatusBadgeVariant(ticket.status)}>
{getTicketStatusLabel(ticket.status)}
</Badge>
</TableCell>
<TableCell>
<ToolTipLabel
label={formatDate(
new Date(ticket.updatedAt),
"MMM d, yyyy 'at' hh:mm a",
)}
>
<div className="text-sm flex items-center gap-2 z-10 relative">
{formatDistanceToNow(new Date(ticket.updatedAt), {
addSuffix: true,
})}
</div>
</ToolTipLabel>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</div>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../../shared/SupportForm_SelectInput";
const ACCOUNT_PROBLEM_AREAS = [
"Pricing inquiry",
"Billing inquiry",
"Usage inquiry",
"Other",
];
export function AccountSupportForm() {
const [problemArea, setProblemArea] = useState<string>("");
const [description, setDescription] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Problem area"
name="extraInfo_Problem_Area"
onValueChange={setProblemArea}
options={ACCOUNT_PROBLEM_AREAS}
promptText="Select a problem area"
required={true}
value={problemArea}
/>
{problemArea && (
<DescriptionInput value={description} onChange={setDescription} />
)}
</>
);
}
@@ -1,15 +1,19 @@
"use client";
import { useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../shared/SupportForm_TextInput";
import { UnitySupportForm } from "../shared/SupportForm_UnityInput";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../../shared/SupportForm_TextInput";
import { UnitySupportForm } from "../../shared/SupportForm_UnityInput";
const AFFECTED_AREAS = ["Dashboard", "Application"];
export const AffectedAreaInput = () => {
const [selectedAffectedArea, setSelectedAffectedArea] = useState<string>("");
const [selectedSDK, setSelectedSDK] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [sdkDescription, setSdkDescription] = useState<string>("");
return (
<>
<SupportForm_SelectInput
@@ -48,16 +52,15 @@ export const AffectedAreaInput = () => {
inputType="url"
required={false}
/>
<DescriptionInput />
<AttachmentForm />
<DescriptionInput
value={sdkDescription}
onChange={setSdkDescription}
/>
</>
)}
</>
) : (
<>
<DescriptionInput />
<AttachmentForm />
</>
<DescriptionInput value={description} onChange={setDescription} />
))}
</>
);
@@ -1,9 +1,8 @@
import { type ReactElement, useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../shared/SupportForm_TextInput";
import { UnitySupportForm } from "../shared/SupportForm_UnityInput";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../../shared/SupportForm_TextInput";
import { UnitySupportForm } from "../../shared/SupportForm_UnityInput";
import { AffectedAreaInput } from "./AffectedAreaInput";
type ProblemAreaItem = {
@@ -35,27 +34,28 @@ const OSSelect = () => {
);
};
const DescriptionInputWrapper = () => {
const [description, setDescription] = useState<string>("");
return <DescriptionInput value={description} onChange={setDescription} />;
};
const PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: <AffectedAreaInput />,
label: "Embedded wallet login issues",
label: "In-app wallet login issues",
},
{
component: <AffectedAreaInput />,
label: "Embedded wallet transaction issues",
label: "In-app wallet transaction issues",
},
{
component: <AffectedAreaInput />,
label: "Embedded wallet Custom Auth",
label: "In-app wallet Custom Auth",
},
{
component: <AffectedAreaInput />,
label: "Account Abstraction",
},
{
component: <AffectedAreaInput />,
label: "In-app wallet",
},
{
component: (
<>
@@ -66,8 +66,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [
inputType="url"
required={false}
/>
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: "Connect SDKs",
@@ -77,8 +76,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [
<>
<UnitySupportForm />
<SDKVersionInput />
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: "Unity SDK",
@@ -101,8 +99,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [
inputType="text"
required={false}
/>
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: ".NET SDK",
@@ -117,7 +114,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [
},
];
export default function ConnectSupportForm() {
export function ConnectSupportForm() {
const [selectedProblemArea, setSelectedProblemArea] = useState("");
return (
@@ -1,8 +1,7 @@
import { type ReactElement, useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../shared/SupportForm_TextInput";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../../shared/SupportForm_TextInput";
type ProblemAreaItem = {
label: string;
@@ -62,6 +61,11 @@ const ContractAffectedAreaInput = () => {
);
};
const DescriptionInputWrapper = () => {
const [description, setDescription] = useState<string>("");
return <DescriptionInput value={description} onChange={setDescription} />;
};
const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: (
@@ -69,8 +73,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [
<NetworkInput />
<ContractTypeInput />
<ContractAffectedAreaInput />
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: "Deploying a contract",
@@ -81,8 +84,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [
<NetworkInput />
<ContractAddressInput />
<ContractTypeInput />
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: "Contract verification",
@@ -94,8 +96,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [
<ContractAddressInput />
<ContractFunctionInput />
<ContractAffectedAreaInput />
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: "Calling a function in my contract",
@@ -103,8 +104,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: "Developing a custom contract",
@@ -112,15 +112,14 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
<DescriptionInputWrapper />
</>
),
label: "Other",
},
];
export default function ContractSupportForm() {
export function ContractSupportForm() {
const [problemArea, setProblemArea] = useState<string>("");
return (
<>
@@ -1,10 +1,9 @@
import { useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../shared/SupportForm_TextInput";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../../shared/SupportForm_TextInput";
const ENGINE_TYPES = ["Cloud-Hosted", "Self-Hosted"];
const ENGINE_TYPES = ["Cloud (V3)", "Dedicated (V2)"];
const ENGINE_PROBLEM_AREAS = [
"SSL Issues",
"Transaction queueing issues",
@@ -13,20 +12,22 @@ const ENGINE_PROBLEM_AREAS = [
"Other",
];
export default function EngineSupportForm() {
export function EngineSupportForm() {
const [selectedEngineType, setSelectedEngineType] = useState<string>("");
const [problemArea, setProblemArea] = useState<string>("");
const [description, setDescription] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Problem area"
formLabel="Type"
name="extraInfo_Engine_Type"
onValueChange={setSelectedEngineType}
options={ENGINE_TYPES}
promptText="Select Engine type"
required={true}
value={selectedEngineType}
/>
/>{" "}
{selectedEngineType && (
<>
<SupportForm_SelectInput
@@ -48,8 +49,7 @@ export default function EngineSupportForm() {
placeholder="https://your-engine-url.com"
required={true}
/>
<DescriptionInput />
<AttachmentForm />
<DescriptionInput value={description} onChange={setDescription} />
</>
)}
</>
@@ -0,0 +1,36 @@
import { useState } from "react";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../../shared/SupportForm_SelectInput";
const SharedOtherProblemComponent = () => {
const [description, setDescription] = useState<string>("");
return <DescriptionInput value={description} onChange={setDescription} />;
};
const OTHER_PROBLEM_AREAS = [
"General inquiry",
"Feature request",
"Bug report",
"Documentation",
"Integration help",
"Other",
];
export function OtherSupportForm() {
const [problemArea, setProblemArea] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Problem area"
name="extraInfo_Problem_Area"
onValueChange={setProblemArea}
options={OTHER_PROBLEM_AREAS}
promptText="Select a problem area"
required={true}
value={problemArea}
/>
{problemArea && <SharedOtherProblemComponent />}
</>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../../shared/SupportForm_SelectInput";
import { SupportForm_TextInput } from "../../shared/SupportForm_TextInput";
const PAYMENT_AREAS = ["Dashboard", "Application"];
export function PaymentsSupportForm() {
const [area, setArea] = useState<string>("");
const [description, setDescription] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Affected Area"
name="extraInfo_Payments_Area"
onValueChange={setArea}
options={PAYMENT_AREAS}
promptText="Select affected area"
required={true}
value={area}
/>
{area === "Application" && (
<>
<SupportForm_TextInput
formLabel="SDK"
formValue="extraInfo_Payments_SDK"
inputType="text"
placeholder="e.g. React, Unity, iOS, etc."
required={true}
/>
<SupportForm_TextInput
formLabel="SDK Version"
formValue="extraInfo_Payments_SDK_Version"
inputType="text"
placeholder="e.g. 3.12.0"
required={true}
/>
<SupportForm_TextInput
formLabel="Application URL"
formValue="extraInfo_Payments_App_URL"
inputType="url"
placeholder="https://your-app-url.com"
required={true}
/>
</>
)}
{(area === "Application" || area === "Dashboard") && (
<DescriptionInput value={description} onChange={setDescription} />
)}
</>
);
}
@@ -0,0 +1,27 @@
import { useState } from "react";
import { DescriptionInput } from "../../shared/SupportForm_DescriptionInput";
import { SupportForm_TextInput } from "../../shared/SupportForm_TextInput";
export function TokensMarketplaceSupportForm() {
const [description, setDescription] = useState<string>("");
return (
<>
<SupportForm_TextInput
formLabel="Address"
formValue="extraInfo_TokensMarketplace_Address"
inputType="text"
placeholder="Enter the relevant address"
required={true}
/>
<SupportForm_TextInput
formLabel="Chain"
formValue="extraInfo_TokensMarketplace_Chain"
inputType="text"
placeholder="e.g. Ethereum, Polygon, etc."
required={true}
/>
<DescriptionInput value={description} onChange={setDescription} />
</>
);
}
@@ -1,29 +1,35 @@
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
type Props = {
placeholder?: string;
};
import { cn } from "@/lib/utils";
const defaultDescription =
"Please describe the issue you're encountering in detail, including steps that led to the error, any error messages, troubleshooting steps you've already taken, and the product(s), dashboard, or SDKs involved.";
export const DescriptionInput = (props: Props) => {
interface Props {
value: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
}
export function DescriptionInput(props: Props) {
return (
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor="markdown">
<div className={cn("flex flex-col gap-2", props.className)}>
<Label className="font-medium text-foreground" htmlFor="markdown">
Description
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
<span className="text-red-500 ml-1 align-middle">*</span>
</Label>
<Textarea
autoComplete="off"
maxLength={10000}
name="markdown"
onChange={(e) => props.onChange(e.target.value)}
value={props.value}
placeholder={props.placeholder ?? defaultDescription}
required
rows={7}
rows={5}
/>
</div>
);
};
}

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