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
318 changed files with 9818 additions and 8441 deletions
+5
View File
@@ -448,6 +448,11 @@ async function redirects() {
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
{
source: "/support/:path*",
destination: "/team/~/~/support",
permanent: false,
},
];
}
+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);
}
@@ -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";
@@ -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>
+4
View File
@@ -85,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>
@@ -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,
@@ -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 { getAuthTokenWalletAddress } from "@/api/auth-token";
import { getTeamById } from "@/api/team";
import { loginRedirect } from "@/utils/redirects";
import { getRawAccount } from "../../../../account/settings/getAccount";
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 "@/utils/redirects";
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>
);
}
@@ -285,7 +285,7 @@ function DeleteAccountCard(props: {
variant="outline"
>
<Link
href="/support/create-ticket"
href="/team/~/~/support"
rel="noopener noreferrer"
target="_blank"
>
@@ -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>
@@ -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>
);
@@ -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>
@@ -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>
);
};
}
@@ -22,11 +22,9 @@ export const SupportForm_SelectInput = (props: Props) => {
return (
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={name}>
<Label className="relative font-medium text-foreground" htmlFor={name}>
{formLabel}
{required && (
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
)}
{required && <span className="text-red-500 ml-1 align-middle">*</span>}
</Label>
<Select
@@ -40,7 +38,7 @@ export const SupportForm_SelectInput = (props: Props) => {
<SelectTrigger>
<SelectValue placeholder={promptText}>{props.value}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent className="bg-background border-border">
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
@@ -1,6 +1,7 @@
import type { HTMLInputTypeAttribute } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type Props = {
formLabel: string;
@@ -8,16 +9,20 @@ type Props = {
required: boolean;
inputType: HTMLInputTypeAttribute | undefined;
placeholder?: string;
className?: string;
};
export const SupportForm_TextInput = (props: Props) => {
const { formLabel, formValue, required, placeholder, inputType } = props;
const { formLabel, formValue, required, placeholder, inputType, className } =
props;
return (
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={formValue}>
<div className={cn("flex flex-col items-start gap-2", className)}>
<Label
className="relative font-medium text-foreground"
htmlFor={formValue}
>
{formLabel}
{required && (
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
)}
{required && <span className="text-red-500 ml-1 align-middle">*</span>}
</Label>
<Input
@@ -1,3 +1,5 @@
"use client";
import { useState } from "react";
import { SupportForm_SelectInput } from "./SupportForm_SelectInput";
import { SupportForm_TextInput } from "./SupportForm_TextInput";
@@ -0,0 +1,164 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type {
SupportMessage,
SupportTicket,
SupportTicketListItem,
} from "../types/tickets";
export async function getSupportTicketsByTeam(params: {
teamSlug: string;
authToken: string;
}): Promise<SupportTicketListItem[]> {
const encodedTeamSlug = encodeURIComponent(params.teamSlug);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/list`;
const response = await fetch(apiUrl, {
body: JSON.stringify({
limit: 50,
descending: true,
}),
cache: "no-store",
headers: {
Accept: "application/json",
"Accept-Encoding": "identity",
Authorization: `Bearer ${params.authToken}`,
"Content-Type": "application/json",
},
method: "POST",
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch support tickets: ${errorText}`);
}
const data: { data?: SupportTicketListItem[] } = await response.json();
const conversations = data.data || [];
return conversations;
}
type RawSupportMessage = {
id: string;
text?: string;
timestamp?: string;
createdAt?: string;
isPrivateNote?: boolean;
sentByUser?: {
name: string;
email: string;
isExternal: boolean;
};
};
export async function getSupportTicket(params: {
ticketId: string;
teamSlug: string;
authToken: string;
}): Promise<SupportTicket> {
const encodedTeamSlug = encodeURIComponent(params.teamSlug);
const encodedTicketId = encodeURIComponent(params.ticketId);
const messagesPayload = {
limit: 1000,
descending: false,
};
const [conversationResponse, messagesResponse] = await Promise.all([
fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}`,
{
cache: "no-store",
headers: {
Accept: "application/json",
"Accept-Encoding": "identity",
Authorization: `Bearer ${params.authToken}`,
"Content-Type": "application/json",
},
method: "GET",
},
),
fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages/list`,
{
body: JSON.stringify(messagesPayload),
cache: "no-store",
headers: {
Accept: "application/json",
"Accept-Encoding": "identity",
Authorization: `Bearer ${params.authToken}`,
"Content-Type": "application/json",
},
method: "POST",
},
),
]);
if (!conversationResponse.ok || !messagesResponse.ok) {
throw new Error(
`Failed to fetch support ticket: ${await conversationResponse.text()}`,
);
}
const conversation: SupportTicket = await conversationResponse.json();
const messagesData: { data?: unknown[] } = await messagesResponse.json();
const rawMessages = messagesData.data || [];
// Transform the raw messages to match our interface
const messages: SupportMessage[] = (rawMessages as RawSupportMessage[])
.filter((msg) => {
// Filter out messages without content - check both text and text fields
const hasContent = msg.text && msg.text.length > 0;
const hasText = msg.text && msg.text.trim().length > 0;
// Filter out private notes - they should not be shown to customers
const isNotPrivateNote = !msg.isPrivateNote;
return (hasContent || hasText) && isNotPrivateNote;
})
.map((msg) => {
// Use text if available and is a non-empty array, otherwise fall back to text
let content = "";
if (typeof msg.text === "string" && msg.text.trim().length > 0) {
content = msg.text;
}
// Clean up 'Email:' line to show only the plain email if it contains a mailto link
if (content) {
content = content
.split("\n")
.map((line) => {
if (line.trim().toLowerCase().startsWith("email:")) {
// Extract email from <mailto:...|...>
const match = line.match(/<mailto:([^|>]+)\|[^>]+>/);
if (match) {
return `Email: ${match[1]}`;
}
}
return line;
})
.join("\n");
}
// Map the author information from sentByUser if available
const author = msg.sentByUser
? {
name: msg.sentByUser.name,
email: msg.sentByUser.email,
type: (msg.sentByUser.isExternal ? "customer" : "user") as
| "user"
| "customer",
}
: undefined;
return {
id: msg.id,
content: content,
createdAt: msg.timestamp || msg.createdAt || "",
timestamp: msg.timestamp || msg.createdAt || "",
author: author,
};
});
conversation.messages = messages;
return conversation;
}
@@ -0,0 +1,38 @@
import { notFound, redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { tryCatch } from "@/utils/try-catch";
import { SupportCaseDetails } from "../../_components/SupportCaseDetails";
import { getSupportTicket } from "../../apis/tickets";
export default async function TicketPage(props: {
params: Promise<{
team_slug: string;
id: string;
}>;
}) {
const params = await props.params;
const [team, token] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team || !token) {
notFound();
}
const ticketResult = await tryCatch(
getSupportTicket({
ticketId: params.id,
teamSlug: params.team_slug,
authToken: token,
}),
);
if (ticketResult.error) {
redirect(`/team/${params.team_slug}/~/support`);
}
return <SupportCaseDetails team={team} ticket={ticketResult.data} />;
}
@@ -0,0 +1,27 @@
import { notFound, redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { CreateSupportCase } from "../_components/CreateSupportCase";
export default async function CreatePage(props: {
params: Promise<{
team_slug: string;
}>;
}) {
const params = await props.params;
const [team, token] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team || !token) {
notFound();
}
if (team.billingPlan === "free") {
redirect(`/team/${params.team_slug}/~/support`);
}
return <CreateSupportCase authToken={token} team={team} />;
}
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { getTeamBySlug } from "@/api/team";
import { SupportHeader } from "./_components/SupportHeader";
export default async function Layout(props: {
params: Promise<{ team_slug: string }>;
children: React.ReactNode;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
if (!team) {
redirect("/team");
}
return (
<div className="flex grow flex-col">
<SupportHeader
teamSlug={team.slug}
isFreePlan={team.billingPlan === "free"}
/>
<div className="container max-w-5xl pt-6 pb-10 flex flex-col grow">
{props.children}
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import { XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team";
import { UpsellContent } from "@/components/blocks/upsell-wrapper";
import { tryCatch } from "@/utils/try-catch";
import { SupportsCaseList } from "./_components/case-list";
import { getSupportTicketsByTeam } from "./apis/tickets";
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
}) {
const params = await props.params;
const [team, token] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team || !token) {
notFound();
}
if (team.billingPlan === "free") {
return (
<div className="flex flex-col grow justify-center items-center">
<UpsellContent
currentPlan={team.billingPlan}
featureDescription="Create support cases for your projects"
featureName="Support Center"
requiredPlan="starter"
teamSlug={params.team_slug}
benefits={[
{
description:
"Create & Manage all your support requests in one place",
status: "available",
},
{
description: "Personalized Assistance tailored to your use case",
status: "available",
},
]}
/>
</div>
);
}
const supportedTicketsResult = await tryCatch(
getSupportTicketsByTeam({
authToken: token,
teamSlug: team.slug,
}),
);
if (supportedTicketsResult.error) {
return (
<div className="min-h-[200px] flex justify-center text-center p-4 items-center border rounded-lg text-sm">
<div className="flex flex-col gap-3 justify-center items-center">
<div className="border p-1 rounded-full">
<XIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-sm">Failed to load support tickets</p>
<p>{supportedTicketsResult.error.message}</p>
</div>
</div>
);
}
return <SupportsCaseList team={team} tickets={supportedTicketsResult.data} />;
}
@@ -0,0 +1,39 @@
export type SupportMessage = {
id: string;
content: string;
createdAt: string;
timestamp: string;
author?: {
name: string;
email: string;
type: "user" | "customer";
};
};
export type SupportTicketStatus =
| "needs_response"
| "in_progress"
| "on_hold"
| "closed"
| "open"
| "resolved";
export type SupportTicket = {
id: string;
status: SupportTicketStatus;
createdAt: string;
updatedAt: string;
closedAt: string | null;
respondedAt: string | null;
messages: SupportMessage[];
title: string;
};
export type SupportTicketListItem = {
id: string;
status: SupportTicketStatus;
createdAt: string;
updatedAt: string;
closedAt: string | null;
title: string;
};
@@ -0,0 +1,46 @@
import type { BadgeProps } from "@/components/ui/badge";
import type { SupportTicketStatus } from "../types/tickets";
export function getTicketStatusBadgeVariant(
status: SupportTicketStatus,
): BadgeProps["variant"] {
switch (status) {
case "open":
return "default";
case "resolved":
return "success";
case "closed":
return "outline";
// invert logic for user
case "in_progress":
return "warning";
// invert logic for user
case "needs_response":
return "default";
case "on_hold":
return "outline";
default:
return "default";
}
}
export function getTicketStatusLabel(status: SupportTicketStatus): string {
switch (status) {
case "closed":
return "Closed";
case "open":
return "Open";
case "resolved":
return "Resolved";
// Invert logic for user - in Progress becomes needs response
case "in_progress":
return "Response Required";
// Invert logic for user - needs response becomes in progress
case "needs_response":
return "In Progress";
case "on_hold":
return "On Hold";
default:
return "In Progress";
}
}
@@ -68,7 +68,7 @@ export function EngineCloudBarChartCardUI({
if (data.length === 0 || isAllEmpty) {
return (
<EmptyStateCard
link="https://portal.thirdweb.com/engine/v3"
link="https://portal.thirdweb.com/transactions"
metric="Transactions"
/>
);
@@ -104,7 +104,7 @@ function EngineLegacyBannerUI(props: {
<Button asChild size="sm" variant="outline">
<Link
className="gap-2 bg-background"
href="https://portal.thirdweb.com/engine/v3"
href="https://portal.thirdweb.com/transactions"
rel="noopener noreferrer"
target="_blank"
>
@@ -6,8 +6,8 @@ import { getTeamBySlug, getTeams } from "@/api/team";
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 { siwaExamplePrompts } from "../../../../(dashboard)/support/definitions";
import { getValidAccount } from "../../../../account/settings/getAccount";
import { TeamHeaderLoggedIn } from "../../../components/TeamHeader/team-header-logged-in.client";
import { StaffModeNotice } from "../../(team)/_components/StaffModeNotice";
@@ -97,17 +97,13 @@ export default async function ProjectLayout(props: {
{props.children}
</ProjectSidebarLayout>
</div>
<div className="fixed right-6 bottom-6 z-50">
<div className="fixed right-4 bottom-4 z-50">
<CustomChatButton
authToken={authToken}
clientId={project.publishableKey}
examplePrompts={siwaExamplePrompts}
isFloating={true}
isLoggedIn={true}
label="Ask AI Assistant"
networks="all"
pageType="support"
teamId={team.id}
team={team}
/>
</div>
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
@@ -155,10 +155,10 @@ export function LaunchNFT(props: {
return shouldDeployERC721 ? "erc721" : "erc1155";
}, [formValues.nfts]);
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
// TODO: enable later when bundler changes are done
const canEnableGasless = false; // props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const showGaslessSection = false; // activeWallet?.id === "inApp";
const contractLink = contractAddressRef.current
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
@@ -64,10 +64,10 @@ export function LaunchTokenStatus(props: {
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
// TODO: enable later when bundler changes are done
const canEnableGasless = false; //props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const showGaslessSection = false; // activeWallet?.id === "inApp";
const { idToChain } = useAllChainsData();
const chainMetadata = idToChain.get(Number(formValues.chain));
@@ -10,6 +10,7 @@ import * as z from "zod";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -31,7 +32,6 @@ import { useAllChainsData } from "@/hooks/chains/allChains";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { TryItOut } from "../server-wallets/components/try-it-out";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
const formSchema = z.object({
secretKey: z.string().min(1, "Secret key is required"),
@@ -193,9 +193,9 @@ function SendTestTransactionModal(props: {
<SelectTrigger className="w-full">
<SelectValue>
<div className="flex items-center gap-2">
<SmartAccountCell
<WalletAddress
client={props.client}
wallet={selectedWallet}
address={selectedWallet.address}
/>
<span className="text-muted-foreground text-sm">
{selectedWallet.metadata.label}
@@ -207,7 +207,10 @@ function SendTestTransactionModal(props: {
{props.wallets.map((wallet, index) => (
<SelectItem key={wallet.address} value={index.toString()}>
<div className="flex items-center gap-2">
<SmartAccountCell client={props.client} wallet={wallet} />
<WalletAddress
client={props.client}
address={wallet.address}
/>
<span className="text-muted-foreground text-sm">
{wallet.metadata.label}
</span>
@@ -331,7 +331,7 @@ function TxStatusCell(props: { transaction: Transaction }) {
const { errorMessage } = transaction;
const minedAt = transaction.confirmedAt;
const status =
(transaction.executionResult?.status as TransactionStatus) ?? null;
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
const onchainStatus =
transaction.executionResult &&
@@ -339,10 +339,6 @@ function TxStatusCell(props: { transaction: Transaction }) {
? transaction.executionResult.onchainStatus
: null;
if (!status) {
return null;
}
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
@@ -58,12 +58,12 @@ export function ServerWalletsTableUI({
totalPages: number;
client: ThirdwebClient;
}) {
const [showSigners, setShowSigners] = useState(false);
const showSignersId = useId();
const [showContractWallets, setShowContractWallets] = useState(false);
const showContractWalletsId = useId();
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-row items-center gap-4 px-6 py-6">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
<div className="flex flex-col lg:flex-row items-center gap-4 px-6 py-6">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none items-start">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Server Wallets
@@ -73,20 +73,26 @@ export function ServerWalletsTableUI({
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<label className="cursor-pointer text-sm" htmlFor={showSignersId}>
Show Signer Addresses
</label>
<Switch
checked={showSigners}
id={showSignersId}
onCheckedChange={() => setShowSigners(!showSigners)}
<div className="flex flex-col gap-4 items-end">
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
/>
</div>
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
</div>
<div className="flex items-center justify-end gap-2 py-4">
<label
className="cursor-pointer text-sm text-muted-foreground"
htmlFor={showContractWalletsId}
>
Show ERC4337 Smart Account Addresses
</label>
<Switch
checked={showContractWallets}
id={showContractWalletsId}
onCheckedChange={() => setShowContractWallets(!showContractWallets)}
/>
</div>
@@ -94,7 +100,9 @@ export function ServerWalletsTableUI({
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>
{showContractWallets ? "Smart Account Address" : "EOA Address"}
</TableHead>
<TableHead>Label</TableHead>
<TableHead className="text-right">Created At</TableHead>
<TableHead className="text-right">Updated At</TableHead>
@@ -105,10 +113,10 @@ export function ServerWalletsTableUI({
{wallets.map((wallet) => (
<TableRow className="hover:bg-accent/50" key={wallet.id}>
<TableCell>
{showSigners ? (
<WalletAddress address={wallet.address} client={client} />
) : (
{showContractWallets ? (
<SmartAccountCell client={client} wallet={wallet} />
) : (
<WalletAddress address={wallet.address} client={client} />
)}
</TableCell>
<TableCell>{wallet.metadata.label || "none"}</TableCell>
@@ -197,7 +205,7 @@ export function ServerWalletsTableUI({
);
}
export function SmartAccountCell({
function SmartAccountCell({
wallet,
client,
}: {
@@ -5,6 +5,7 @@ import { WebhookConfigModal } from "./webhook-config-modal";
interface CreateWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
@@ -19,6 +20,7 @@ export function CreateWebhookConfigModal(props: CreateWebhookConfigModalProps) {
mode="create"
onOpenChange={props.onOpenChange}
open={props.open}
onSuccess={props.onSuccess}
projectSlug={props.projectSlug}
supportedChainIds={props.supportedChainIds}
teamSlug={props.teamSlug}
@@ -5,6 +5,7 @@ import { WebhookConfigModal } from "./webhook-config-modal";
interface EditWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
@@ -20,6 +21,7 @@ export function EditWebhookConfigModal(props: EditWebhookConfigModalProps) {
mode="edit"
onOpenChange={props.onOpenChange}
open={props.open}
onSuccess={props.onSuccess}
projectSlug={props.projectSlug}
supportedChainIds={props.supportedChainIds}
teamSlug={props.teamSlug}
@@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -10,9 +11,11 @@ import { z } from "zod";
import {
createWebhookConfig,
type Topic,
testDestinationUrl,
updateWebhookConfig,
type WebhookConfig,
} from "@/api/webhook-configs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -29,10 +32,10 @@ import {
FormItem,
FormLabel,
FormMessage,
RequiredFormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Switch } from "@/components/ui/switch";
import { TopicSelectorModal } from "./topic-selector-modal";
const formSchema = z.object({
@@ -56,6 +59,7 @@ interface WebhookConfigModalProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
@@ -66,6 +70,11 @@ interface WebhookConfigModalProps {
export function WebhookConfigModal(props: WebhookConfigModalProps) {
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
statusCode: number;
responseBody?: string;
} | null>(null);
const queryClient = useQueryClient();
const isEditMode = props.mode === "edit";
@@ -131,14 +140,20 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
}
},
onError: (error) => {
toast.error(`Failed to ${isEditMode ? "update" : "create"} webhook`, {
description: error.message,
});
toast.error(
isEditMode ? "Failed to update webhook" : "Failed to create webhook",
{
description: error.message,
},
);
},
onSuccess: () => {
toast.success(
`Webhook ${isEditMode ? "updated" : "created"} successfully`,
isEditMode
? "Webhook updated successfully"
: "Webhook created successfully",
);
props.onSuccess();
props.onOpenChange(false);
if (!isEditMode) {
form.reset();
@@ -149,10 +164,72 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
},
});
const testMutation = useMutation({
mutationFn: async (destinationUrl: string) => {
const result = await testDestinationUrl({
teamIdOrSlug: props.teamSlug,
projectIdOrSlug: props.projectSlug,
destinationUrl,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.result;
},
onError: (error) => {
setTestResult({
success: false,
statusCode: 0,
responseBody: error.message,
});
toast.error("Failed to test webhook", {
description: error.message,
});
},
onSuccess: (data) => {
const is2xx = data.httpStatusCode >= 200 && data.httpStatusCode < 300;
setTestResult({
success: is2xx,
statusCode: data.httpStatusCode,
responseBody: data.httpResponseBody,
});
if (is2xx) {
toast.success(`Success! Status: ${data.httpStatusCode}`, {
description: data.httpResponseBody,
});
} else {
toast.error(`Failed! Status: ${data.httpStatusCode}`, {
description: data.httpResponseBody,
});
}
},
});
function onSubmit(values: FormValues) {
mutation.mutate(values);
}
function handleTestWebhook() {
const destinationUrl = form.getValues("destinationUrl");
// Validate URL
try {
const url = new URL(destinationUrl);
if (!url.protocol.startsWith("https:")) {
toast.error("URL must start with https://");
return;
}
} catch {
toast.error("Please enter a valid URL");
return;
}
setTestResult(null);
testMutation.mutate(destinationUrl);
}
function handleOpenChange(open: boolean) {
if (!open && !mutation.isPending) {
if (isEditMode && webhookConfig) {
@@ -182,7 +259,7 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
<div className="p-6">
<DialogHeader className="mb-4">
<DialogTitle className="font-semibold text-2xl tracking-tight">
{isEditMode ? "Edit" : "Create"} Webhook Configuration
{isEditMode ? "Edit Webhook" : "Create Webhook"}
</DialogTitle>
</DialogHeader>
@@ -192,7 +269,7 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<RequiredFormLabel>Description</RequiredFormLabel>
<FormControl>
<Input
placeholder={
@@ -213,20 +290,50 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
name="destinationUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Destination URL</FormLabel>
<RequiredFormLabel>Destination URL</RequiredFormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhook"
type="url"
{...field}
onChange={(e) => {
field.onChange(e);
setTestResult(null);
testMutation.reset();
}}
/>
</FormControl>
<FormDescription>
{isEditMode
? "The URL where webhook events will be sent"
: "Enter your webhook URL. Only https:// is supported."}
Enter your webhook URL. Only https:// is supported.
</FormDescription>
<FormMessage />
{/* Test Webhook Button */}
<div className="flex items-center gap-3 mt-3">
<Button
disabled={
!field.value ||
!field.value.startsWith("https://") ||
testMutation.isPending
}
variant="outline"
onClick={handleTestWebhook}
type="button"
size="sm"
>
{testMutation.isPending && (
<Spinner className="size-4 mr-2" />
)}
{!testMutation.isPending &&
testResult &&
(testResult.success ? (
<CheckIcon className="size-4 mr-2 text-green-500" />
) : (
<XIcon className="size-4 mr-2 text-red-500" />
))}
Test Webhook
</Button>
</div>
</FormItem>
)}
/>
@@ -250,33 +357,15 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
</Button>
</FormControl>
<FormDescription>
{isEditMode
? "Select the events you want to receive notifications for"
: "Select the events to trigger calls to your webhook."}
Select the events to receive real-time notifications
</FormDescription>
{field.value && field.value.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{field.value.map((topic) => {
return (
<div
className="flex items-center gap-1 rounded-full bg-accent px-2 py-1 text-xs"
key={topic.id}
>
<Badge variant="secondary" key={topic.id}>
{topic.id}
<button
className="ml-1 text-muted-foreground hover:text-foreground"
onClick={() => {
field.onChange(
field.value?.filter(
(t) => t.id !== topic.id,
),
);
}}
type="button"
>
×
</button>
</div>
</Badge>
);
})}
</div>
@@ -285,39 +374,14 @@ export function WebhookConfigModal(props: WebhookConfigModalProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name="isPaused"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm font-medium">
{isEditMode ? "Paused" : "Start Paused"}
</FormLabel>
<FormDescription className="text-xs">
{isEditMode
? "Pause webhook notifications"
: "Do not send events yet. You can unpause at any time."}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<DialogFooter className="mt-4 gap-4 border-border border-t bg-card p-6 lg:gap-2">
<DialogFooter className="mt-4 border-border border-t bg-card p-6 flex justify-between">
<Button
disabled={mutation.isPending}
onClick={() => handleOpenChange(false)}
variant="outline"
variant="ghost"
>
Cancel
</Button>
@@ -12,13 +12,14 @@ import {
MoreHorizontalIcon,
PauseIcon,
PlusIcon,
SendIcon,
TrashIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import type { Topic, WebhookConfig } from "@/api/webhook-configs";
import { deleteWebhookConfig } from "@/api/webhook-configs";
import { deleteWebhookConfig, testDestinationUrl } from "@/api/webhook-configs";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
@@ -29,6 +30,7 @@ import {
DropdownMenuRadioGroup,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {
Table,
TableBody,
@@ -38,6 +40,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { WebhookSummaryStats } from "@/types/analytics";
import { CreateWebhookConfigModal } from "./create-webhook-config-modal";
@@ -67,7 +70,9 @@ export function WebhookConfigsTable(props: {
const [deletingWebhook, setDeletingWebhook] = useState<WebhookConfig | null>(
null,
);
const [testingWebhookId, setTestingWebhookId] = useState<string | null>(null);
const queryClient = useQueryClient();
const router = useDashboardRouter();
const deleteMutation = useMutation({
mutationFn: async (webhookId: string) => {
@@ -94,9 +99,64 @@ export function WebhookConfigsTable(props: {
queryClient.invalidateQueries({
queryKey: ["webhook-configs", props.teamSlug, props.projectSlug],
});
router.refresh();
},
});
const testMutation = useMutation({
mutationFn: async ({
destinationUrl,
webhookId,
}: {
destinationUrl: string;
webhookId: string;
}) => {
setTestingWebhookId(webhookId);
const result = await testDestinationUrl({
teamIdOrSlug: props.teamSlug,
projectIdOrSlug: props.projectSlug,
destinationUrl,
});
if (result.status === "error") {
throw new Error(result.body);
}
return { result: result.result, webhookId };
},
onError: (error) => {
setTestingWebhookId(null);
toast.error("Failed to test webhook", {
description: error.message,
});
},
onSuccess: (data) => {
const is2xx =
data.result.httpStatusCode >= 200 && data.result.httpStatusCode < 300;
setTestingWebhookId(null);
if (is2xx) {
toast.success(`Success! Status: ${data.result.httpStatusCode}`, {
description: data.result.httpResponseBody,
});
} else {
toast.error(`Failed! Status: ${data.result.httpStatusCode}`, {
description: data.result.httpResponseBody,
});
}
},
});
function handleTestWebhook(config: WebhookConfig) {
if (!config.destinationUrl) {
toast.error("No destination URL to test");
return;
}
testMutation.mutate({
destinationUrl: config.destinationUrl,
webhookId: config.id,
});
}
const sortedConfigs = useMemo(() => {
let _configsToShow = webhookConfigs;
@@ -147,7 +207,6 @@ export function WebhookConfigsTable(props: {
</p>
</div>
{/* Filters + Add New */}
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<SortDropdown
hasActiveFilters={hasActiveFilters}
@@ -168,7 +227,6 @@ export function WebhookConfigsTable(props: {
</div>
</div>
{/* Webhook Configs Table */}
{paginatedConfigs.length === 0 ? (
<div className="flex min-h-[300px] grow items-center justify-center border rounded-lg border-dashed bg-card">
<div className="flex flex-col items-center">
@@ -298,6 +356,22 @@ export function WebhookConfigsTable(props: {
<EditIcon className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleTestWebhook(config);
}}
>
<span className="mr-2 h-4 w-4 flex items-center justify-center">
{testingWebhookId === config.id ? (
<Spinner className="size-4" />
) : (
<SendIcon className="size-4" />
)}
</span>
Test Webhook
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onClick={() => {
@@ -331,6 +405,9 @@ export function WebhookConfigsTable(props: {
<CreateWebhookConfigModal
client={props.client}
onOpenChange={setIsCreateModalOpen}
onSuccess={() => {
router.refresh();
}}
open={isCreateModalOpen}
projectSlug={props.projectSlug}
supportedChainIds={props.supportedChainIds}
@@ -344,6 +421,9 @@ export function WebhookConfigsTable(props: {
onOpenChange={(open) => {
if (!open) setEditingWebhook(null);
}}
onSuccess={() => {
router.refresh();
}}
open={!!editingWebhook}
projectSlug={props.projectSlug}
supportedChainIds={props.supportedChainIds}
@@ -363,6 +443,7 @@ export function WebhookConfigsTable(props: {
onConfirm={() => {
if (deletingWebhook) {
deleteMutation.mutate(deletingWebhook.id);
router.refresh();
}
}}
onOpenChange={(open) => {
@@ -23,7 +23,6 @@ import { ClientOnly } from "@/components/blocks/client-only";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookies";
import { NEXT_PUBLIC_TURNSTILE_SITE_KEY } from "@/constants/public-envs";
import type { Account } from "@/hooks/useApi";
@@ -93,15 +92,6 @@ export function LoginAndOnboardingPage(props: {
Docs
</Link>
<Link
className="px-2 text-muted-foreground text-sm hover:text-foreground"
href="/support"
rel="noopener noreferrer"
target="_blank"
>
Support
</Link>
<Link
className="px-2 text-muted-foreground text-sm hover:text-foreground"
href="https://feedback.thirdweb.com"
@@ -152,13 +142,6 @@ function LoginPageContainer(props: { children: React.ReactNode }) {
You can instead log into your account using your account email
address.
</p>
<p>
Please{" "}
<UnderlineLink href="/support">
reach out to support
</UnderlineLink>{" "}
if you need help.
</p>
</div>
</div>
</footer>
@@ -146,8 +146,8 @@ export const MarkdownRenderer: React.FC<{
li: ({ children: c, ...props }) => (
<li
className={cn(
"mb-1.5 text-muted-foreground leading-loose [&>p]:m-0",
markdownProps.li?.className,
"mb-1.5 text-muted-foreground leading-loose [&>p]:m-0",
)}
{...cleanedProps(props)}
>
@@ -156,16 +156,16 @@ export const MarkdownRenderer: React.FC<{
),
ol: (props) => (
<ol
className="mb-4 list-outside list-decimal pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
{...cleanedProps(props)}
className="mb-4 list-outside list-decimal pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
/>
),
p: (props) => (
<p
className={cn(
"mb-4 text-muted-foreground leading-loose",
markdownProps.p?.className,
"mb-4 text-muted-foreground leading-loose",
)}
{...cleanedProps(props)}
/>
@@ -200,8 +200,8 @@ export const MarkdownRenderer: React.FC<{
ul: (props) => {
return (
<ul
className="mb-4 list-outside list-disc pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
{...cleanedProps(props)}
className="mb-4 list-outside list-disc pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
/>
);
},
@@ -401,19 +401,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",
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
/>
@@ -41,7 +41,6 @@ export function PayTransactionPreview() {
return (
<TransactionWidget
amount={"0.001"}
client={THIRDWEB_CLIENT}
description={nft?.metadata?.description}
image={nft?.metadata?.image}
File diff suppressed because one or more lines are too long
+7 -5
View File
@@ -5,7 +5,7 @@
---
**UI Components**
---
# Universal Bridge
# Payments
---
## Widgets
* [Widgets.BuyWidget](https://portal.thirdweb.com/references/typescript/v5/widgets/BuyWidget): Widget is a prebuilt UI for purchasing a specific token.
@@ -21,7 +21,6 @@
* [WalletProvider](https://portal.thirdweb.com/references/typescript/v5/WalletProvider): A React context provider component that supplies Wallet-related data to its child components.
---
# Wallet Connection
* [AutoConnectProps](https://portal.thirdweb.com/references/typescript/v5/AutoConnect): Enable Account abstraction for all wallets. This will connect to the users's smart account based on the connected personal wallet and the given options.
* [ConnectButton](https://portal.thirdweb.com/references/typescript/v5/ConnectButton): A fully featured wallet connection component that allows to:
* [ConnectEmbed](https://portal.thirdweb.com/references/typescript/v5/ConnectEmbed): An inline wallet connection component that allows to:
* [SiteEmbed](https://portal.thirdweb.com/references/typescript/v5/SiteEmbed): Embeds another thirdweb-supported site for seamless in-app and ecosystem wallet connection.
@@ -135,7 +134,7 @@
* [DEPLOY.deployERC1155Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC1155Contract): Deploys an thirdweb ERC1155 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.deployERC20Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC20Contract): Deploys an thirdweb ERC20 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.deployERC721Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC721Contract): Deploys an thirdweb ERC721 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.deployPackContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployPackContract): \[Pack contract is incompatible with Pectra update. Support for this contract is being removed in next release.\]
* [DEPLOY.deployPublishedContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployPublishedContract): Deploy an instance of a published contract on a given chain
* [DEPLOY.deploySplitContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deploySplitContract): Deploys a thirdweb [Split contract](https://thirdweb.com/thirdweb.eth/Split)On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
* [DEPLOY.prepareDeterministicDeployTransaction](https://portal.thirdweb.com/references/typescript/v5/deploy/prepareDeterministicDeployTransaction): Deploy a contract deterministically - will maintain the same address across chains. This is meant to be used with published contracts configured with the 'direct deploy' method. Under the hood, this uses a keyless transaction with a common create2 factory.
@@ -982,7 +981,7 @@
* [TransferableERC721.setTransferable](https://portal.thirdweb.com/references/typescript/v5/transferableerc721/setTransferable): Prepares a transaction to call the "setTransferable" function on the contract.
* [TransferableERC721.setTransferableFor](https://portal.thirdweb.com/references/typescript/v5/transferableerc721/setTransferableFor): Prepares a transaction to call the "setTransferableFor" function on the contract.
---
# Universal Bridge
# Payments
---
## Buy
* [Buy.prepare](https://portal.thirdweb.com/references/typescript/v5/buy/prepare): Prepares a _finalized_ Universal Bridge quote for the provided buy request with transaction data. This function will return everything `quote` does, with the addition of a series of prepared transactions and the associated expiration timestamp.
@@ -1029,6 +1028,7 @@
* [ERC7579.erc7579](https://portal.thirdweb.com/references/typescript/v5/erc7579): Config for a ERC7579 modular smart wallet.
---
# Wallet Connection
* [AutoConnectProps](https://portal.thirdweb.com/references/typescript/v5/AutoConnect): Enable Account abstraction for all wallets. This will connect to the users's smart account based on the connected personal wallet and the given options.
* [AutoConnect](https://portal.thirdweb.com/references/typescript/v5/autoConnect): AutoConnect last connected wallet on page reload or revisit. Note: If you are using `ConnectButton` or `ConnectEmbed` components, You don't need to use this component as it is already included.
---
# Wallet Utilities
@@ -1113,7 +1113,7 @@
* [getTransactions](https://portal.thirdweb.com/references/typescript/v5/getTransactions): Get transactions for a wallet
---
# Engine
* [createServerWallet](https://portal.thirdweb.com/references/typescript/v5/createServerWallet): Create a server wallet.
* [createServerWallet](https://portal.thirdweb.com/references/typescript/v5/createServerWallet): Create a new server wallet.
* [getServerWallets](https://portal.thirdweb.com/references/typescript/v5/getServerWallets): List all server wallets.
* [getTransactionStatus](https://portal.thirdweb.com/references/typescript/v5/getTransactionStatus): Get the execution status of a transaction.
* [searchTransactions](https://portal.thirdweb.com/references/typescript/v5/searchTransactions): Search for transactions by their ids.
@@ -1254,6 +1254,8 @@
* [installPublishedExtension](https://portal.thirdweb.com/references/typescript/v5/installPublishedExtension): Install a published extension on a dynamic contract
* [uninstallExtension](https://portal.thirdweb.com/references/typescript/v5/uninstallExtension): Uninstall an extension on a dynamic contract
* [activateStylusContract](https://portal.thirdweb.com/references/typescript/v5/activateStylusContract): Activate a stylus contract by calling ArbWasm Precompile
* [deployWithStylusConstructor](https://portal.thirdweb.com/references/typescript/v5/deployWithStylusConstructor): Deploy stylus contract with constructor params
* [isContractActivated](https://portal.thirdweb.com/references/typescript/v5/isContractActivated): Reference for isContractActivated
* [convertCryptoToFiat](https://portal.thirdweb.com/references/typescript/v5/convertCryptoToFiat): Get a price of a token (using tokenAddress + chainId) in fiat. Only USD is supported at the moment.
* [convertFiatToCrypto](https://portal.thirdweb.com/references/typescript/v5/convertFiatToCrypto): Convert a fiat value to a token. Currently only USD is supported.
* [getBuyHistory](https://portal.thirdweb.com/references/typescript/v5/getBuyHistory): Get Buy transaction history for a given wallet address.
+18 -7
View File
@@ -711,6 +711,7 @@ const walletRedirects = {
};
const paymentRedirects = {
"/pay": "/payments",
"/checkouts": "/payments",
"/checkouts/api-reference": "/payments/nft-checkout/api-reference",
"/checkouts/checkout-link": "/payments/nft-checkout/checkout-link",
@@ -741,7 +742,6 @@ const paymentRedirects = {
"/connect/pay/buy-with-fiat": "/connect/pay/overview",
"/connect/pay/enable-test-mode": "/connect/pay/guides/enable-test-mode",
"/connect/pay/guides/enable-test-mode": "/connect/pay/testing-pay",
"/payments/:match*": "/connect/pay/overview",
};
const contractRedirects = {
@@ -921,7 +921,6 @@ const otherRedirects = {
// connect
"/connect/connect": "/connect/sign-in",
"/connect/ecosystems/:path*": "/connect/wallet/overview",
"/connect/embedded-wallet/:path*": "/connect/in-app-wallet/:path*",
"/connect/embedded-wallet/how-to/get-embedded-wallet-details-on-server":
"/connect/in-app-wallet/how-to/get-in-app-wallet-details-on-server",
"/connect/in-app-wallet/how-to/get-in-app-wallet-details-on-server":
@@ -1016,11 +1015,23 @@ const otherRedirects = {
"/unity/wallets/providers/smart-wallet":
"/unity/wallets/providers/account-abstraction",
"/wallet/get-started/overview": "/connect/wallet/overview",
// wallets > connect redirects
"/wallets": "/connect",
"/wallets/auth/:path*": "/connect/auth/:path*",
"/wallets/connect/:path*": "/connect/connect/:path*",
"/wallets/embedded-wallet/:path*": "/connect/embedded-wallet/:path*",
// connect > wallets
"/connect/:path*": "/wallets/:path*",
"/connect/wallet/:path*": "/wallets/:path*",
"/connect/auth/:path*": "/wallets/auth/:path*",
"/connect/connect/:path*": "/wallets/connect/:path*",
"/connect/embedded-wallet/:path*": "/wallets/embedded-wallet/:path*",
"/connect/wallet/sign-in-methods/configure": "/wallets/users",
"/connect/wallet/server": "/wallets/server",
"/connect/account-abstraction/get-started": "/wallets/sponsor-gas",
"/connect/account-abstraction/:path*": "/wallets/sponsor-gas",
"/wallets/in-app-wallet/custom-auth/configuration": "/wallets/custom-auth",
"/connect/wallets/user-management/link-multiple-identity":
"/wallets/link-profiles",
"/connect/wallets/user-management/export-private-key":
"/wallets/export-private-key",
"/wallets/wallet/server": "/wallets/server",
"/wallets/wallet/sign-in-methods/configure": "/wallets/users",
// account abstraction rename
"/wallets/smart-wallet/:path*": "/connect/account-abstraction/:path*",
"/web3-api/:path*": "/infrastructure/engine/overview",
+13 -7
View File
@@ -32,15 +32,15 @@ import { ThirdwebIcon } from "../icons/thirdweb";
const links = [
{
href: "/connect",
href: "/wallets",
name: "Wallets",
},
{
href: "/pay",
href: "/payments",
name: "Payments",
},
{
href: "/engine",
href: "/transactions",
name: "Transactions",
},
{
@@ -196,9 +196,10 @@ const supportLinks = [
export function Header() {
const [showBurgerMenu, setShowBurgerMenu] = useState(false);
const router = useRouter();
const pathname = usePathname();
return (
<header className="flex w-full flex-col gap-2 border-b bg-background p-2 lg:px-4">
<header className="flex w-full flex-col gap-2 border-b bg-background pt-4 px-4 lg:px-8 overflow-hidden">
<div className="container flex items-center justify-between gap-6">
{/* Top row */}
<div className="flex items-center gap-2">
@@ -273,7 +274,7 @@ export function Header() {
{links.map((link) => {
return (
<li
className="flex items-center"
className="flex items-center py-4 relative overflow-hidden"
key={link.href}
onClick={() => {
setShowBurgerMenu(false);
@@ -283,6 +284,9 @@ export function Header() {
}}
>
<NavLink href={link.href} name={link.name} />
{pathname.includes(link.href) && (
<div className="bg-violet-700 h-1 inset-x-0.5 rounded-full absolute -bottom-0.5" />
)}
</li>
);
})}
@@ -506,7 +510,9 @@ function NavLink(props: {
<Link
className={clsx(
"font-medium text-base transition-colors hover:text-foreground xl:text-sm",
pathname === props.href ? "text-foreground" : "text-muted-foreground ",
pathname.includes(props.href)
? "text-foreground"
: "text-muted-foreground",
props.icon ? "flex flex-row gap-3" : "",
)}
href={props.href}
@@ -515,7 +521,7 @@ function NavLink(props: {
>
{props.icon ? (
<>
<props.icon className="size-6 text-muted-foreground" />
<props.icon className="size-6" />
<span className="my-auto">{props.name}</span>
</>
) : (
@@ -1,490 +0,0 @@
# Thirdweb Bundler - API Reference
The Thirdweb Bundler is a service that provides Account Abstraction (ERC-4337 and EIP-7702) bundler functionality with integrated paymaster services, zkSync chains support, and EIP-7702 transaction delegation.
## Table of Contents
- [Url format](#url-format)
- [Authentication](#authentication)
- [Request Format](#request-format)
- [Paymaster Methods](#paymaster-methods)
- [User Operation Methods](#user-operation-methods)
- [Gas Price Methods](#gas-price-methods)
- [Status and Tracking Methods](#status-and-tracking-methods)
- [zkSync-Specific Methods](#zksync-specific-methods)
- [EIP-7702 Methods](#eip-7702-methods)
- [Standard ERC-4337 Methods](#standard-erc-4337-methods)
- [Error Responses](#error-responses)
- [Policy Enforcement](#policy-enforcement)
## Url format
- **Chain Parameter**: the chain id is always specified in the subdomain (e.g., `<chain_id>.bundler.thirdweb.com`)
- **Version Parameter**: the version is specified in the path, we recommend using `/v2`
```
https://<chain_id>.bundler.thirdweb.com/v2
```
## Authentication
All requests require authentication via:
- **Client ID**: Provided as `X-Client-Id: <client_id>` header - used for frontend authentication
- **Secret Key**: Provided as `X-Secret-Key: <secret_key>` header - used for backend authentication
## Request Format
All RPC requests follow the JSON-RPC 2.0 specification:
```json
{
"id": 1,
"jsonrpc": "2.0",
"method": "<method_name>",
"params": [...]
}
```
## Paymaster Methods
### `pm_sponsorUserOperation`
**Purpose**: Sponsors a user operation by providing paymaster data to cover gas costs.
**Parameters**:
```json
[
{
"sender": "0x...", // Address of the smart account
"nonce": "0x...", // Nonce for the operation
"initCode": "0x...", // Account initialization code (if needed)
"callData": "0x...", // The call data to execute
"callGasLimit": "0x...", // Gas limit for the call
"verificationGasLimit": "0x...", // Gas limit for verification
"preVerificationGas": "0x...", // Pre-verification gas
"maxFeePerGas": "0x...", // Maximum fee per gas
"maxPriorityFeePerGas": "0x...", // Maximum priority fee per gas
"paymasterAndData": "0x...", // Paymaster data (EntryPoint v0.6)
// OR for EntryPoint v0.7:
"paymaster": "0x...", // Paymaster address
"paymasterData": "0x...", // Paymaster data
"paymasterVerificationGasLimit": "0x...",
"paymasterPostOpGasLimit": "0x..."
},
"0x...", // Entry point address
"0x...", // Chain ID (hex)
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"paymaster": "0x...", // Paymaster address
"paymasterData": "0x...", // Paymaster data
"paymasterAndData": "0x..." // Combined paymaster data (v0.6)
}
}
```
### `pm_getPaymasterData`
**Purpose**: Gets paymaster data for sponsoring a user operation (functionally equivalent to `pm_sponsorUserOperation`).
**Parameters**: Same as `pm_sponsorUserOperation`
**Response**: Same as `pm_sponsorUserOperation`
### `pm_getPaymasterStubData`
**Purpose**: Gets stub paymaster data for gas estimation purposes without actual sponsorship.
**Parameters**: Same as `pm_sponsorUserOperation`
**Response**: Similar to `pm_sponsorUserOperation` but with placeholder data for estimation.
### `pm_getAcceptedPaymentTokens`
**Purpose**: Returns the list of tokens accepted for gas payments by the paymaster.
**Parameters**: `[]`
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"acceptedTokens": []
}
}
```
## User Operation Methods
### `eth_estimateUserOperationGas`
**Purpose**: Estimates gas limits required for a user operation.
**Parameters**:
```json
[
{
"sender": "0x...", // Smart account address
"nonce": "0x...", // Account nonce
"initCode": "0x...", // Initialization code (if deploying)
"callData": "0x...", // Transaction call data
"maxFeePerGas": "0x...", // Maximum fee per gas
"maxPriorityFeePerGas": "0x..." // Maximum priority fee
},
"0x...", // Entry point address
{} // State overrides (optional)
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"preVerificationGas": "0x...", // Pre-verification gas
"verificationGasLimit": "0x...", // Verification gas limit
"callGasLimit": "0x...", // Call gas limit
// For EntryPoint v0.7:
"paymasterVerificationGasLimit": "0x...",
"paymasterPostOpGasLimit": "0x..."
}
}
```
### `eth_sendUserOperation`
**Purpose**: Submits a user operation to the bundler for execution on-chain.
**Parameters**: Same format as the user operation object used in other methods.
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": "0x..." // User operation hash
}
```
## Gas Price Methods
### `thirdweb_getUserOperationGasPrice`
**Purpose**: Gets current gas prices optimized for user operations.
**Parameters**: `[]`
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"maxFeePerGas": "0x...", // Recommended max fee per gas
"maxPriorityFeePerGas": "0x..." // Recommended priority fee
}
}
```
## Status and Tracking Methods
### `thirdweb_getUserOperationStatus`
**Purpose**: Gets the current status of a submitted user operation.
**Parameters**:
```json
["0x..."] // User operation hash (hex string)
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"status": "not_found|not_submitted|submitted|rejected|included|failed|queued",
"transactionHash": "0x..." // Transaction hash (null if not yet included)
}
}
```
**Status Values**:
- `not_found`: User operation not found
- `not_submitted`: Not yet submitted to mempool
- `submitted`: Submitted to mempool
- `rejected`: Rejected by bundler
- `included`: Successfully included in a block
- `failed`: Transaction failed on-chain
- `queued`: Queued for processing
## zkSync-Specific Methods
### `zk_paymasterData`
**Purpose**: Gets paymaster data for zkSync chains to enable gasless transactions.
**Parameters**:
```json
[
{
"nonce": "0x...", // Transaction nonce
"from": "0x...", // Sender address
"to": "0x...", // Recipient address
"gas": "0x...", // Gas limit
"gasPrice": "0x...", // Gas price
"value": "0x...", // ETH value to send
"data": "0x...", // Transaction data
"maxFeePerGas": "0x...", // EIP-1559 max fee
"maxPriorityFeePerGas": "0x...", // EIP-1559 priority fee
"chainId": "0x..." // Chain ID
}
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"paymaster": "0x...", // Paymaster contract address
"paymasterInput": "0x..." // Encoded paymaster input data
}
}
```
### `zk_broadcastTransaction`
**Purpose**: Broadcasts a signed transaction on zkSync chains.
**Parameters**:
```json
[
{
"nonce": "0x...",
"from": "0x...",
"to": "0x...",
"gas": "0x...",
"gasPrice": "0x...",
"value": "0x...",
"data": "0x...",
"maxFeePerGas": "0x...",
"maxPriorityFeePerGas": "0x...",
"chainId": "0x...",
"signedTransaction": "0x..." // Raw signed transaction bytes
}
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": "0x..." // Transaction hash
}
```
## EIP-7702 Methods
### `tw_execute`
**Purpose**: Executes transactions using EIP-7702 (Account Abstraction via Authorization Lists). This enables EOAs to temporarily act as smart contracts.
**Parameters**:
```json
[
"0x...", // EOA address that will execute the calls
{
"calls": [
{
"target": "0x...", // Contract address to call
"value": "0x...", // ETH value to send (hex)
"data": "0x..." // Call data
}
// ... more calls
],
"uid": "0x..." // Unique identifier for the batch (bytes32)
},
"0x...", // Signature authorizing the execution
{
// Authorization list (optional)
"chainId": 1,
"address": "0x...",
"nonce": "0x...",
"yParity": 0,
"r": "0x...",
"s": "0x..."
}
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"queueId": "..." // Queue ID for tracking the transaction
}
}
```
### `tw_getTransactionHash`
**Purpose**: Gets the transaction hash for a queued EIP-7702 transaction.
**Parameters**:
```json
["queue_id"] // Queue ID returned from tw_execute
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"transactionHash": "0x..." // Transaction hash (null if not yet mined)
}
}
```
## Standard ERC-4337 Methods
The service supports all the standard ERC-4337 bundler methods:
- `eth_chainId` - Returns the current chain ID
- `eth_getUserOperationByHash` - Retrieves user operation by hash
- `eth_getUserOperationReceipt` - Gets the receipt for a user operation
- `eth_supportedEntryPoints` - Lists supported EntryPoint contracts
- Any other standard bundler RPC methods defined in ERC-4337
## Error Responses
All methods may return error responses following the JSON-RPC 2.0 error format:
```json
{
"id": 1,
"jsonrpc": "2.0",
"error": {
"code": 400,
"message": "Detailed error description"
}
}
```
### Common Error Codes
- **400**: Invalid parameters, malformed request, or unsupported chain
- **401**: Invalid authentication credentials or insufficient permissions
- **500**: Internal server error
- **502**: Upstream bundler provider error
### Example Error Scenarios
```json
// Invalid chain
{
"error": "Invalid chain: unsupported_chain",
"chain": "unsupported_chain",
"code": 400
}
// Mainnet access denied
{
"error": "Mainnets not enabled for this account, please enable billing...",
"code": 401
}
// Invalid user operation hash
{
"error": "Invalid userOpHash parameter",
"code": 400
}
```
## Policy Enforcement
Paymaster methods support policy-based access control. When a transaction is subject to policy evaluation:
### Policy Check Process
1. The system evaluates paymaster requests against configured policies
2. Policies can restrict based on:
- Contract addresses being called
- Transaction data
- Gas limits
- Sender addresses
- Custom endpoint logic
### Policy Rejection Response
If a request is rejected by policy, the response format is:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"policyId": "policy_123", // ID of the policy that rejected the request
"reason": "Transaction rejected by policy: Unauthorized contract call"
}
}
```
### Affected Methods
Policy enforcement applies to:
- `pm_sponsorUserOperation`
- `pm_getPaymasterData`
- `zk_paymasterData`
## Rate Limits and Quotas
- Rate limits may apply based on your billing plan
- Mainnet access requires billing information
- Testnet access is available on free plans
## Supported Networks
For the most up-to-date list of supported chains, check the [thirdweb dashboard](https://thirdweb.com/dashboard).
## SDK Integration
For easier integration, consider using the thirdweb SDKs:
- [JavaScript/TypeScript SDK](/typescript)
- [React SDK](/react)
- [Unity SDK](/unity)
- [Dotnet SDK](/dotnet)
- [Unreal Engine SDK](/unreal)
@@ -1,90 +0,0 @@
import { createMetadata } from "@doc";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
# Batching transactions
export const metadata = createMetadata({
image: {
title: "Batching transactions",
icon: "wallets",
},
title: "Batching transactions | thirdweb",
description: "How to batch transactions with smart accounts",
});
Batching transactions allows sending multiple transactions in a single user operation. This can be useful to save on fees, reduce number of user confirmations or to ensure that multiple transactions are executed atomically.
A typical example is to do an approval and a transfer in a single userOperation. This way, the transfer will only happen if the approval is successful.
<Tabs defaultValue="react">
<TabsList>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
</TabsList>
<TabsContent value="react">
```tsx
import { useActiveAccount, useSendBatchTransaction } from "thirdweb/react";
import { approve, transferFrom } from "thirdweb/extensions/erc20";
const smartAccount = useActiveAccount();
const { mutate: sendBatchTransaction } = useSendBatchTransaction();
const approveAndTransfer = async () => {
const transactions = [
approve({
contract,
spender: "0x...",
value: 100,
}),
transferFrom({
contract,
from: "0x...",
to: "0x...",
amount: 100,
}),
];
await sendBatchTransaction(transactions);
};
```
</TabsContent>
<TabsContent value="typescript">
```tsx
import { smartWallet } from "thirdweb/wallets";
import { sendBatchTransaction } from "thirdweb";
import { approve, transferFrom } from "thirdweb/extensions/erc20";
const smartWallet = new smartWallet(config);
const smartAccount = await smartWallet.connect({
client,
personalAccount,
});
const transactions = [
approve({
contract,
spender: "0x...",
value: 100,
}),
transferFrom({
contract,
from: "0x...",
to: "0x...",
amount: 100,
}),
];
await sendBatchTransaction({
transactions,
account: smartAccount,
});
```
</TabsContent>
</Tabs>
@@ -1,102 +0,0 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
# ERC-20 Paymaster
In traditional Ethereum transactions, users pay gas fees in the native cryptocurrency of the network, such as ETH. This requirement can create friction, especially for applications where users interact with smart contracts without holding native tokens. ERC-20 Paymaster enables users pay gas fees using ERC-20 tokens. [Learn more about ERC-20 Paymasters.](https://blog.thirdweb.com/account-abstraction-paymaster-pay-evm-gas-fees-with-usdt-usdc-or-any-other-erc-20-token/)
### Get Started
Configure the Paymaster In your SDK code, specify the ERC-20 token you wish to use for gas fees.
Currently you may use Lisk LSK, Base USDC or Celo CUSD.
<Tabs defaultValue="typescript">
<TabsList>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="dotnet">.NET</TabsTrigger>
<TabsTrigger value="unity">Unity</TabsTrigger>
</TabsList>
<TabsContent value="typescript">
```tsx
import { base, lisk, celo } from "thirdweb/chains";
import { TokenPaymaster, smartWallet } from "thirdweb/wallets";
// Have users pay for gas in Base USDC
const usdcWallet = smartWallet({
chain: base,
sponsorGas: true,
overrides: {
tokenPaymaster: TokenPaymaster.BASE_USDC,
},
});
// Have users pay for gas in Lisk LSK
const lskWallet = smartWallet({
chain: lisk,
sponsorGas: true,
overrides: {
tokenPaymaster: TokenPaymaster.LISK_LSK,
},
});
// Have users pay for gas in Celo CUSD
const cusdWallet = smartWallet({
chain: celo,
sponsorGas: true,
overrides: {
tokenPaymaster: TokenPaymaster.CELO_CUSD,
},
});
```
</TabsContent>
<TabsContent value="react">
```tsx
import { base, lisk, celo } from "thirdweb/chains";
import { TokenPaymaster, smartWallet } from "thirdweb/wallets";
// Have users pay for gas in Base USDC
<ConnectButton
client={client}
accountAbstraction={{
chain: base,
sponsorGas: true,
overrides: {
tokenPaymaster: TokenPaymaster.BASE_USDC,
},
});
```
</TabsContent>
<TabsContent value="dotnet">
```csharp
var chainId = 1135; // lisk
var erc20SmartWallet = await SmartWallet.Create(
personalWallet: privateKeyWallet,
chainId: chainId,
tokenPaymaster: TokenPaymaster.LISK_LSK
);
```
</TabsContent>
<TabsContent value="unity">
```csharp
var chainId = 8453; // base
var erc20SmartWallet = await ConnectWallet(
new WalletOptions(
WalletProvider.InAppWallet,
chainId,
inAppWalletOptions: new InAppWalletOptions(authprovider: AuthProvider.Steam),
smartWalletOptions: new SmartWalletOptions(sponsorGas: true, tokenPaymaster: TokenPaymaster.BASE_USDC)
)
);
```
</TabsContent>
</Tabs>
@@ -1,78 +0,0 @@
import {
Grid,
Callout,
OpenSourceCard,
ArticleIconCard,
createMetadata,
} from "@doc";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { WalletsSmartIcon } from "@/icons";
export const metadata = createMetadata({
image: {
title: "Account Factories",
icon: "wallets",
},
title: "Deploy your own ERC-4337 Account Factories | thirdweb",
description:
"Customize the behavior of smart accounts by deploying your own account factory contracts.",
});
# Account Factories
By default, the SDK uses a global account factory deployed on most chains:
- [`AccountFactory`](https://thirdweb.com/thirdweb.eth/AccountFactory) at address [`0x85e23b94e7F5E9cC1fF78BCe78cfb15B81f0DF00`](https://thirdweb.com/1/0x85e23b94e7F5E9cC1fF78BCe78cfb15B81f0DF00) for Entrypoint v0.6.
- [`AccountFactory_0_7`](https://thirdweb.com/thirdweb.eth/AccountFactory_0_7) at address [`0x4be0ddfebca9a5a4a617dee4dece99e7c862dceb`](https://thirdweb.com/1/0x4be0ddfebca9a5a4a617dee4dece99e7c862dceb) for Entrypoint v0.7.
The default account factory deploys standard, immutable accounts with the features listed below. However, you can customize the behavior of your apps smart accounts by deploying your own account factory contracts.
## Deploy your own Account Factory contract
You can deploy your own Account Factory via the [explore page](https://thirdweb.com/explore/smart-wallet) or build your own [ERC 4337](https://eips.ethereum.org/EIPS/eip-4337) compatible factory contract using the [Solidity SDK](/contracts/build/base-contracts/erc-4337).
thirdweb offers different kinds of prebuilt account factories:
<Grid>
<ArticleIconCard
title="Account Factory for Entrypoint v0.6"
href="https://thirdweb.com/thirdweb.eth/AccountFactory"
icon={WalletsSmartIcon}
/>
<ArticleIconCard
title="Managed Account Factory for Entrypoint v0.6"
href="https://thirdweb.com/thirdweb.eth/ManagedAccountFactory"
icon={WalletsSmartIcon}
/>
<ArticleIconCard
title="Account Factory for Entrypoint v0.7"
href="https://thirdweb.com/thirdweb.eth/AccountFactory_0_7"
icon={WalletsSmartIcon}
/>
<ArticleIconCard
title="Managed Account Factory for Entrypoint v0.7"
href="https://thirdweb.com/thirdweb.eth/ManagedAccountFactory_0_7"
icon={WalletsSmartIcon}
/>
</Grid>
#### Account Factories comparison
| Factory Type | Upgradeability | Expected Usage |
| -------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`Standard`](https://thirdweb.com/thirdweb.eth/AccountFactory) | Non-upgradeable | Developer wants to issue simple smart accounts to their users. They do not anticipate that users wallets will need any feature upgrades. |
| [`Managed`](https://thirdweb.com/thirdweb.eth/ManagedAccountFactory) | Account upgrades controlled centrally by the app developer. | Developer wants to issue smart accounts to their users. They do anticipate feature upgrades to user wallets, and want to push upgrades to user wallets for seamless/invisible UX for upgrades. |
#### Account comparison
| | Simple | Managed |
| --------------------------------------------------- | -------------------------- | ---------------------------------------- |
| Contracts | `AccountFactory` `Account` | `ManagedAccountFactory` `ManagedAccount` |
| ERC-4337 Compatible | ✅ | ✅ |
| Can hold native ERC-20, ERC-721 and ERC-1155 Tokens | ✅ | ✅ |
| Multi-signer | ✅ | ✅ |
| Execute transactions in Batches | ✅ | ✅ |
| Is the Account Upgradeable | ❌ | ✅ |
| Who controls upgrades (?) | n/a | Admin of the account factory. |
To learn more about each factory and their respective account implementations, read our [smart contract deep dive](https://blog.thirdweb.com/smart-contract-deep-dive-building-smart-wallets-for-individuals-and-teams/).
@@ -1,33 +0,0 @@
import { createMetadata } from "@doc";
# FAQs
export const metadata = createMetadata({
image: {
title: "FAQs about Account abstraction",
icon: "wallets",
},
title: "FAQs | thirdweb",
description:
"Find answers to frequently asked questions about account abstraction and smart accounts.",
});
### Can users have the same smart account contract address on all EVM chains?
Yes, by using our Account factory contracts, it is possible to enable predictable addresses per user across all EVM chains. To do this, ensure that your **account factory** has the same address on every chain. You can accomplish this from the dashboard by enabling "Deterministic address" when deploying the account factory.
### Can I sign transactions with my smart account?
Smart contract wallets cannot sign transactions themselves, only the 'owners' of the smart account can. The way to achieve this is to follow [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) which requires to ask the smart contract wallet if the recovered address is indeed an owner.
### Can I use In-App Wallets with account abstraction?
Yes! In-App Wallets with account abstraction is a powerful combination that allows users to sign in using their email, social media accounts, or custom JWT method (In-App Wallet), and provides a completely gasless user experience.
### Are smart accounts custodial or non-custodial?
Smart accounts are non-custodial. It uses smart contracts to operate and manage assets via an admin wallet that signs on behalf of the smart account
### Is setting a trusted forwarder necessary for account abstraction?
No, There is no need to use a trusted forwarder when implementing account abstraction for your contract.
@@ -1,62 +0,0 @@
import { SDKCard, Grid, DocImage, createMetadata, Step, Steps, InstallTabs, OpenSourceCard } from "@doc";
# Biconomy
Learn to setup gasless transactions using TransactionButton and Biconomy's gasless relayers.
This guide assumes you have already installed Connect SDK into your application. If you have not, please refer to the [quickstart guide](/connect/quickstart).
<Steps>
<Step title="API Id & Key">
Obtain an API Id and Key using Biconomy. For steps on how to obtain the API Id and Key, refer to the [Biconomy documentation](https://docs-gasless.biconomy.io/guides/biconomy-dashboard).
</Step>
<Step title="Forwarder Address">
Obtain a Forwarder Address using Biconomy for the specified network. The full list of forwarder addresses can be found on [Biconomy documentation](https://docs-gasless.biconomy.io/misc/contract-addresses)
</Step>
<Step title="Add transaction button">
Add a transaction button for the method you want to sponsor funds for. Pass in the `apiId` and `apiKey` obtained earlier from Biconomy's dashboard.
```jsx
function Example() {
return (
<TransactionButton
transaction={() => {
// Create a transaction object and return it
const tx = prepareContractCall({
contract,
method: "mint",
params: [address, amount],
});
return tx;
}}
onTransactionSent={(result) => {
console.log("Transaction submitted", result.transactionHash);
}}
onTransactionConfirmed={(receipt) => {
console.log("Transaction confirmed", receipt.transactionHash);
}}
onError={(error) => {
console.error("Transaction error", error);
}}
gasless={{
provider: "biconomy",
relayerForwarderAddress: "...",
apiId: "...",
apiKey: "...",
}}
>
Confirm Transaction
</TransactionButton>
);
}
```
</Step>
</Steps>
@@ -1,57 +0,0 @@
import { SDKCard, Grid, DocImage, createMetadata, Step, Steps, InstallTabs, OpenSourceCard } from "@doc";
# Engine
Learn to setup gasless transactions using TransactionButton and Engine
This guide assumes you have already installed Connect SDK into your application. If you have not, please refer to the [quickstart guide](/connect/quickstart).
<Steps>
<Step title="Relayer URL">
Obtain the Relayer URL using Engine. For steps on how to obtain the Relayer URL, refer to the [Engine relayer guide](/engine/v2/features/relayers).
</Step>
<Step title="Add transaction button">
Add a transaction button for the method you want to sponsor funds for. Pass in the `relayerUrl` and `relayerForwarderAddress`
```jsx
function Example() {
return (
<TransactionButton
transaction={() => {
// Create a transaction object and return it
const tx = prepareContractCall({
contract,
method: "mint",
params: [address, amount],
});
return tx;
}}
onTransactionSent={(result) => {
console.log("Transaction submitted", result.transactionHash);
}}
onTransactionConfirmed={(receipt) => {
console.log("Transaction confirmed", receipt.transactionHash);
}}
onError={(error) => {
console.error("Transaction error", error);
}}
gasless={{
provider: "engine",
relayerUrl: "...",
relayerForwarderAddress: "...",
}}
>
Confirm Transaction
</TransactionButton>
);
}
```
</Step>
</Steps>
@@ -1,63 +0,0 @@
import { SDKCard, Grid, DocImage, createMetadata, Step, Steps, InstallTabs, OpenSourceCard } from "@doc";
# OpenZeppelin
Learn to setup gasless transactions using TransactionButton and OpenZeppelin's gasless relayers.
This guide assumes you have already installed Connect SDK into your application. If you have not, please refer to the [quickstart guide](/connect/quickstart).
<Steps>
<Step title="Relayer URL">
Obtain the Relayer URL using OpenZeppelin. For steps on how to obtain the Relayer URL, refer to the [OpenZeppelin documentation](https://docs.openzeppelin.com/defender/v2/manage.html#relayers).
<Step title="Forwarder Address">
Obtain a Forwarder Address using Biconomy for the specified network. Learn how to obtain a forwarder address from [OpenZeppelin documentation](https://docs.openzeppelin.com/contracts/5.x/api/metatx)
</Step>
</Step>
<Step title="Add transaction button">
Add a transaction button for the method you want to sponsor funds for. Pass in the `relayerUrl` and `relayerForwarderAddress`
```jsx
function Example() {
return (
<TransactionButton
transaction={() => {
// Create a transaction object and return it
const tx = prepareContractCall({
contract,
method: "mint",
params: [address, amount],
});
return tx;
}}
onTransactionSent={(result) => {
console.log("Transaction submitted", result.transactionHash);
}}
onTransactionConfirmed={(receipt) => {
console.log("Transaction confirmed", receipt.transactionHash);
}}
onError={(error) => {
console.error("Transaction error", error);
}}
gasless={{
provider: "openzeppelin",
relayerUrl: "...",
relayerForwarderAddress: "...",
}}
>
Confirm Transaction
</TransactionButton>
);
}
```
</Step>
</Steps>
@@ -1,181 +0,0 @@
import { Grid, OpenSourceCard, ArticleIconCard, Steps, Step } from "@doc";
import { WalletsSmartIcon } from "@/icons";
import { createMetadata } from "@doc";
export const metadata = createMetadata({
image: {
title: "Using Account Abstraction in React",
icon: "react",
},
title: "Using Account Abstraction React | thirdweb",
description:
"By using the wallet SDK alongside the React SDK, you can use smart accounts in your front-end applications easily.",
});
# Using Account Abstraction in React
By using the [TypeScript SDK](/typescript/v5), you can use smart accounts in your front-end applications easily.
<Steps>
<Step title="Create an API key">
To use the bundler and paymaster, you must create an API key and a billing account.
To create an API Key:
- Head to the settings page in the dashboard and click the **API Keys** tab.
- Click on **Create API Key**.
- Follow the steps to create your API key.
To use account abstraction infrastructure on mainnet you will also need to [create an account and add a payment method](https://thirdweb.com/team/~/~/settings/billing).
</Step>
<Step title="Create an App">
To use smart accounts in a React app you can either:
- Start from a template like our [Next.js](https://github.com/thirdweb-example/next-starter) or [Vite](https://github.com/thirdweb-example/vite-starter) starter.
- Add the dependencies to an existing project using `npm i thirdweb` or your favorite package manager.
In this guide, we'll use the [Next.js starter template](https://github.com/thirdweb-example/next-starter).
Clone the repo and follow the instructions in the README to set up the project.
</Step>
<Step title="Build your connection UI">
#### Using Connect Wallet (prebuilt UI)
To use the [Connect Button](/references/typescript/v5/ConnectButton) component to connect your users to your app using smart accounts, we just need to pass the `accountAbstraction` prop.
You can change the configuration based on your requirements.
```tsx
import { ConnectButton } from "thirdweb/react";
import { sepolia } from "thirdweb/chains";
const client = createThirdwebClient({ clientId: "your-client-id" });
function App() {
return (
<div className="App">
<ConnectButton
client={client}
accountAbstraction={{
chain: sepolia,
sponsorGas: true,
}}
/>
</div>
);
}
```
Clicking on the connect button will show the following Modal which allows you to connect a personal wallet. This is the personal wallet you are using to initialize the smart account.
After connecting your personal wallet, a smart account is created for you and connected to the application.
#### Using the `useConnect` hook (custom UI)
The [useConnect](/references/typescript/v5/useConnect) hook allows you to programmatically connect your application to the wallet. You will need to build your own UI for connecting the wallet.
```tsx
import { useConnect } from "thirdweb/react";
import { inAppWallet } from "thirdweb/wallets";
import { sepolia } from "thirdweb/chains";
function Example() {
// 1. set the `accountAbstraction` configuration
const { connect } = useConnect({
client,
accountAbstraction: {
chain: sepolia,
sponsorGas: true,
},
});
const connectToSmartAccount = async () => {
// 2. connect with the admin wallet of the smart account
connect(async () => {
const wallet = inAppWallet(); // or any other wallet
await wallet.connect({
client,
chain: sepolia,
strategy: "google",
});
return wallet;
});
};
return <button onClick={() => connectToSmartAccount()}>Connect</button>;
}
```
</Step>
<Step title="Use the Smart account">
Now that you have connected your smart account to your app, you can use it to send transactions and interact with smart contracts.
Deploy a NFT Drop contract from the [explore page](https://thirdweb.com/explore/nft-drop) or build your own [ERC 721](https://eips.ethereum.org/EIPS/eip-721) compatible contract using the [Solidity SDK](/contracts/build/overview).
Use the dashboard to upload some NFTs that we will claim with our smart account.
To claim an NFT, we will use the [TransactionButton](/references/typescript/v5/TransactionButton) component to send the transaction.
```tsx
import { getContract } from "thirdweb";
import { useActiveAccount, TransactionButton } from "thirdweb/react";
import { claimTo } from "thirdweb/extensions/erc721";
const contract = getContract({ client, chain, address: "0x..." });
// The ThirdwebProvider setup above already handles the connection to the smart account
// Within the provider, you can use the SDK normally to interact with the blockchain
export default function MyComponent() {
// Get the connected smart account
const smartAccount = useActiveAccount();
// Fetch owned NFTs
const { data, isLoading } = useReadContract(
getOwnedNFTs,
{
contract,
address: smartAccount.address!,
},
{
enabled: !!smartAccount,
},
);
// Mint a new NFT
return (
<TransactionButton
transaction={() => {
if (!account) return;
return claimTo({
contract,
to: account.address,
quantity: 1,
});
}}
onTransactionConfirmed={() => {
alert("NFT Minted!");
}}
>
Mint NFT
</TransactionButton>
);
}
```
</Step>
<Step title="Conclusion">
In this guide, we learned how to connect users to a React app using two methods:
- With the Connect Wallet component.
- With a custom UI component via the `useConnect` hook.
</Step>
</Steps>
@@ -1,165 +0,0 @@
import { Grid, OpenSourceCard, ArticleIconCard, Steps, Step } from "@doc";
import { WalletsSmartIcon } from "@/icons";
import { createMetadata } from "@doc";
export const metadata = createMetadata({
image: {
title: "Get Started with Account Abstraction",
icon: "typescript",
},
title: "Using Account Abstraction in Typescript | thirdweb",
description:
"By using the wallet SDK alongside the TypeScript SDK, you can use smart accounts in your applications easily",
});
# Using Account abstraction in Typescript
By using the [TypeScript SDK](/typescript/v5), you can use smart accounts in your applications easily.
## Example Use Cases
The wallet SDK with the TypeScript SDK is primarily used when creating a backend for your application or when creating a node script.
In this guide, we will be using the wallet SDK to create a Node script but the logic for creating a backend is the same.
If you are working in a React environment, you are recommended to follow [this guide](/connect/account-abstraction/guides/react).
<Steps>
<Step title="Create an API key">
To use the bundler and paymaster, you must create an API key and a billing account.
To create an API Key:
- Head to the settings page in the dashboard and click the **API Keys** tab.
- Click on **Create API Key**.
- Follow the steps to create your API key.
To use account abstraction infrastructure on mainnet you will also need to [create an account and add a payment method](https://thirdweb.com/team/~/~/settings/billing).
</Step>
<Step title="Create a Node Script">
To use smart accounts in a node script, simply create a new Node.js project and install the `thirdweb` package.
```bash
npm i thirdweb
```
Create a `.env` file and add the following:
```bash
THIRDWEB_SECRET_KEY=<YOUR_SECRET_KEY>
PRIVATE_KEY=<A_TEST_WALLET_PRIVATE_KEY>
```
Create an `index.ts` file where we'll write our script.
</Step>
<Step title="Creating the Personal Wallet Key">
This smart account is unlocked by a 'key' - a personal wallet.
This key can be anything from a MetaMask wallet or an In-App Wallet or just a private key and is used as a way to 'sign in' to the smart account.
To create a personal wallet key, we are going to use the [`privateKeyAccount`](/references/typescript/v5/privateKeyAccount), which we need to import from the `thirdweb/wallets` package.
```typescript
import { createThirdwebClient } from "thirdweb";
import { privateKeyAccount } from "thirdweb/wallets";
const client = createThirdwebClient({
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
});
const personalAccount = privateKeyAccount({
client,
privateKey: process.env.PRIVATE_KEY as string,
});
console.log("Personal account address:", personalAccount.address);
```
</Step>
<Step title="Creating the Smart account">
Now, let's create a smart account using `smartWallet` from `thirdweb/wallets` package.
To do this, we need to pass an object of type [SmartWalletOptions](/references/typescript/v5/SmartWalletOptions) to the function.
Some of the important properties in this object are:
- `chain`: the chain that the smart account will be deployed on.
- `sponsorGas`: whether the smart account should have sponsored transactions or not.
Once we have created the smart wallet interface, we can connect the personal wallet to the smart account using the `connect` method.
```typescript
import { smartWallet } from "thirdweb/wallets";
// Configure the smart wallet
const wallet = smartWallet({
chain: sepolia,
sponsorGas: true,
});
// Connect the smart wallet
const smartAccount = await wallet.connect({
client,
personalAccount,
});
```
</Step>
<Step title="Using the Smart Account">
Now that we have created a smart account object and connected it, we can use it to perform onchain actions gaslessly.
In this example, we will claim a NFT using the `claimTo` method and then send the transaction using the `sendTransaction` method.
```typescript
const balance = await getWalletBalance({
client,
chain,
address: smartAccount.address,
});
console.log("Smart account balance:", balance.displayValue);
const contract = getContract({
client,
chain: sepolia,
address: "0x...", // deploy a drop contract from thirdweb.com/explore
});
const transaction = await claimTo({
contract,
to: smartAccount.address,
quantity: 1,
});
const { transactionHash } = await sendTransaction({
transaction,
smartAccount,
});
console.log(`Minted NFT with transaction hash: ${transactionHash}`);
```
We have also passed our `secretKey` to the SDK so that we can use the bundler and paymaster.
We have also added some helpful logs to view the smart account address and balance using the associated `balance` and `getAddress` methods.
</Step>
<Step title="Run the Script">
To run the script, run the following command:
```bash
bun index.ts
```
As you can see in the terminal output, upon claiming the token, the smart account is deployed. This is because smart account contracts are deployed when the first transaction is initiated.
We have successfully deployed a smart account and claimed an ERC20 token!
</Step>
<Step title="Conclusion">
In this guide, we have learned how to use the wallet SDK with the TypeScript SDK to
create a smart account and claim an NFT.
</Step>
</Steps>
@@ -1,93 +0,0 @@
import { createMetadata } from "@doc";
# What is a Smart Account?
export const metadata = createMetadata({
image: {
title: "How Smart Accounts Works",
icon: "wallets",
},
title: "What is a Smart Account? | thirdweb",
description:
"Smart Account is a wallet that is controlled by a smart contract following the ERC-4337 specification",
});
A Smart account is a smart contract wallet that follows the [ERC-4337 specification](https://eips.ethereum.org/EIPS/eip-4337).
Ethereum has 2 types of accounts to transfer and receive tokens: EOAs (Externally Owned Accounts) and Contract Accounts. A smart account is a wallet managed by a contract account
instead of an EOA. A smart account is a wallet type requiring **no private keys or seed phrases**.
Smart accounts rely on code instead of private keys to secure and recover wallet information.
**A smart account is a type of Web3 wallet powered by smart contracts.**
This smart account is unlocked by a 'key' - a personal account. This key can be anything from a [MetaMask wallet](/connect/external-wallets/io.metamask) or even an [In-App Wallet](/connect/in-app-wallet/overview) and is used as a way to 'sign in' to the wallet.
## Benefits of Smart Accounts
1. Optimized transaction efficiency & batch transactions
- Combine multiple transactions into a single atomic transaction
2. Improved Security Features
3. Social & Multi-Sig Recovery
- Recover a wallet without seed phrases/passwords
- Nominate a trusted person to recover your wallet in the case of an emergency
4. Programmability
- Smart accounts can be programmed to do anything a smart contract can do
- Smart accounts can be upgraded to add new features
## Account Factories
In order to issue smart accounts for users, an account factory contract must be used.
This factory contract is responsible for deploying individual user contracts when required.
The SDK provides a global factory ready to use, but you can also deploy your own factory.
## Terminology
### Personal Wallet/Key
This is the default admin on an account or the "key" to an account. It can be any wallet and is used to initialize the account.
Only one wallet can only be the "key" to one account per factory contract.
This wallet is the primary way to access and interact with the account.
### Account
The account is the ERC-4337 compatible smart contract which holds all of the assets.
### UserOperations
This is the data structure for the "pseudo-transaction" that the user wants to perform. The transaction is pseudo as Smart Accounts cannot initiate transactions on EVM chains as they are not supported natively. It contains the following fields:
- `sender`: The account making the operation.
- `nonce`: Anti-replay parameter; also used as the salt for first-time account creation.
- `initCode`: The initialization code needed to create the account (needed if and only if the account is not yet onchain).
- `callData`: The data to pass to the `sender` during the operation.
- `callGasLimit`: The amount of gas to allocate for the operation.
- `verificationGasLimit`: The amount of gas to allocate for the verification step.
- `preVerificationGas`: The amount of gas to pay to compensate the bundler for pre-verification execution and calldata.
- `maxFeePerGas`: Maximum fee per gas (similar to [EIP-1559](https://eips.ethereum.org/EIPS/eip-1559) `max_fee_per_gas`).
- `maxPriorityFeePerGas`: Maximum priority fee per gas (similar to [EIP-1559](https://eips.ethereum.org/EIPS/eip-1559)).
- `paymasterAndData`: Address of the paymaster sponsoring the transaction, followed by extra data to send to the paymaster (empty for self-sponsored transaction).
- `signature`: Data passed into the account along with the nonce during the verification step. Should depend on the `chainid` and `EntryPoint` address to prevent replay attacks.
### EntryPoint
The EntryPoint is a singleton contract (a contract that has a predictable address that is the same on every chain).
It has two methods that are used as entry points to execute bundles of UserOperations: `handleOps` and `handleAggregatedOps`.
### Bundler (relayer)
A bundler is a node that monitors the alternative mempool of `UserOperations` and bundles multiple `UserOps` together to forward to the EntryPoint contract as a single transaction.
These `UserOps` can be sent from different accounts and are bundled and sent to the `EntryPoint` contract via a `handleOps` call.
The bundler is controlled by its own EOA which initially pays for the gas fees upfront and is then repaid by either the sender or a paymaster if the transaction is sponsored.
The entry point contract then uses the `validateOp` and `executeOp` functions on the smart account contract to verify and execute the `UserOps` on behalf of the users.
It allows you to send transactions with smart accounts.
### Paymaster
A paymaster is a smart contract that relays transactions. It provides a service that enables a third party to pay
the transaction fee on behalf of the user by funding the Paymaster contract in advance. The paymaster acts as a gas reserve which then
can be used during the call execution via the `EntryPoint` contract. The thirdweb [Account abstraction](/connect/account-abstraction) paymaster is initially paid for by thirdweb. By using an API key,
the usage is tracked and billed. You can easily define [sponsorship rules](/connect/account-abstraction/sponsorship-rules) that determine whether a transaction will be sponsored.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

@@ -1,110 +0,0 @@
import { createMetadata, DocImage } from "@doc";
import SupportedChains from "../../../_images/supported-chains.png";
export const metadata = createMetadata({
image: {
title: "Bundler & Paymaster Infrastructure",
icon: "thirdweb",
},
title: "Bundler & Paymaster Infrastructure | thirdweb",
description:
"The thirdweb SDK handles all the heavy lifting of bundling operations and covering gas fees with a turn-key infrastructure. The thirdweb paymaster and bundler services are stand-alone and can be used with any smart account contract without using the thirdweb SDKs",
});
# Bundler & Paymaster Infrastructure
The thirdweb SDK handles all the heavy lifting of bundling operations and covering gas fees with a turn-key infrastructure.
The thirdweb paymaster and bundler services are **stand alone** and can be used with any smart account contract without using the thirdweb SDKs.
On testnets, the only requirement is to obtain a free client id to get started.
Once you're ready to deploy on mainnets, you will require an active [billing account](https://thirdweb.com/team/~/~/settings/billing).
You can configure your client ID to restrict interactions only with your own contracts or with any contract.
### Supported chains
With a thirdweb API key, you get access to bundler and paymaster infrastructure on the following chains:
[View all supported chains with Account Abstraction](https://thirdweb.com/chainlist?service=account-abstraction).
<DocImage src={SupportedChains} />
To support a chain not listed, [contact us](https://thirdweb.com/contact-us).
## Using Paymaster & Bundler URL
To use the Paymaster & Bundler URL for thirdweb, you can follow the guide below:
### URL Pattern
The URL pattern is predictable for all chains and is used for both paymaster and bundler operations:
```
https://<chain_id>.bundler.thirdweb.com/v2/<thirdweb-client-id>
```
Replace `<chain_id>` with the appropriate chain ID and `<thirdweb-client-id>` with your thirdweb client ID.
### Client ID in Header
You can also pass the client ID via the header:
```
x-client-id: <your_thirdweb_client_id>
```
### Frontend Calls
For frontend calls, the calling domain needs to be allowlisted. This can be done from the thirdweb dashboard in the API key settings.
### Backend Usage
For backend usage, the secret key is required as a header, and the client ID can be omitted:
```
x-secret-key: <your_thirdweb_secret_key>
```
Make sure to replace the placeholders with your actual values. This guide will help you set up and use Paymaster & Bundler URLs effectively with thirdweb.
## Using a Custom Bundler
Smart accounts are free to use with your own account abstraction infrastructure. To use your own paymaster & bundler,
pass the `bundlerUrl` the `SmartWalletOptions` overrides:
```ts
const config: SmartWalletOptions = {
chain,
sponsorGas: true,
overrides: {
bundlerUrl: "your-bundler-url",
},
};
```
## Using a Custom Paymaster
You can also provide an entirely custom paymaster logic by providing a `paymaster` function to the `SmartWalletOptions` overrides.
```ts
const config: SmartWalletOptions = {
chain,
sponsorGas: true,
overrides: {
paymaster: async (userOp: UserOperation) => {
// your custom paymaster logic
return myPaymasterResultLogic(userOp);
},
},
};
```
## Pricing & Billing
To use thirdweb's account abstraction infrastructure, you need to set up a billing account on the thirdweb dashboard Settings page.
**Pricing**:
- **Bundler**: Transaction bundle calls (non-sponsored calls) are free to use.
- **Paymaster**: 10% premium on top of network fee based on your usage.
Find more information on the different billing tiers by visiting [thirdweb's pricing page](https://thirdweb.com/pricing).
@@ -1,94 +0,0 @@
import { OpenSourceCard, Grid, SDKCard, createMetadata, FeatureCard } from "@doc";
import { ReactIcon, TypeScriptIcon, UnityIcon } from "@/icons";
export const metadata = createMetadata({
image: {
title: "thirdweb Account Abstraction",
icon: "wallets",
},
title: "Account Abstraction | thirdweb",
description:
"Everything you need to leverage account abstraction technology to enable seamless user experiences",
});
# Account Abstraction
Everything you need to leverage account abstraction technology to enable seamless user experiences for your users. You get all the tools to integrate account abstraction into your app. This includes:
- Account factory contracts that let you spin up smart accounts for your users
- Bundler, which is the infrastructure needed to process account abstraction transactions (known as User Ops)
- Paymaster, which lets you sponsor transaction fees for your users
## Features
<div
className="my-4 grid gap-2 md:grid-cols-2 lg:grid-cols-2 "
>
<FeatureCard
title="Execute Transactions"
description="Enable exchange of native currencies and NFTs to smart account"
iconUrl="/icons/feature-cards/transactions.svg"
/>
<FeatureCard
title="Account Factory Contracts"
description="Deploy ERC-4337 through dashboard, CLI, and SDKs"
iconUrl="/icons/feature-cards/account-factory.svg"
/>
<FeatureCard
title="Sponsor Transaction Fees"
description="Sponsor transaction fees for users using Paymaster services"
iconUrl="/icons/feature-cards/gasless.svg"
/>
<FeatureCard
title="Provision Access"
description="Provide provisioned access with restrictions using session keys"
iconUrl="/icons/feature-cards/session-keys.svg"
/>
<FeatureCard
title="Bundler"
description="Equipped with transaction processing software"
iconUrl="/icons/feature-cards/bundler.svg"
/>
<FeatureCard
title="Enable permissions"
description="Assign signer and admin roles for granular access"
iconUrl="/icons/feature-cards/permissions.svg"
/>
<FeatureCard
title="Composable API"
description="Compatible with external bundler and paymaster services or non-thirdweb ERC-4337 contracts"
iconUrl="/icons/feature-cards/composable-api.svg"
/>
<FeatureCard
title="Upgradeable"
description="Managed version of factory contracts to enable future upgrades"
iconUrl="/icons/feature-cards/upgrade.svg"
/>
</div>
## Demos
Learn by example with these open-source demos:
<OpenSourceCard
title="Account Abstraction Demos"
description="A reference implementation to create and interact with smart accounts."
href={"https://github.com/thirdweb-example/account-abstraction"}
/>
## References
By using the thirdweb SDKs, you can easily create and manage smart accounts for your users:
<Grid>
<SDKCard href="/typescript/v5" title="TypeScript" icon={TypeScriptIcon} />
<SDKCard href="/unity" title="Unity" icon={UnityIcon} />
</Grid>
@@ -1,215 +0,0 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { createMetadata } from "@doc";
export const metadata = createMetadata({
image: {
title: "Account Permissions & Session Keys",
icon: "thirdweb",
},
title: "Account Permissions & Session Keys | thirdweb",
description:
"All of the account contracts - Simple and Managed - share the same permission model. In this section, we'll describe this permission model in detail",
});
# Account Permissions & Session Keys
All of the account contracts - [Simple](https://thirdweb.com/thirdweb.eth/AccountFactory) and [Managed](https://thirdweb.com/thirdweb.eth/ManagedAccountFactory) - share the same permission model. In this section, we'll describe this permission model in detail.
An account recognizes only two types of actors: _Session Keys_ and _Admins_.
## 1. Admins
Admins have **unrestricted access** to the account; call any functions on the contract, use the contract without going through the ERC-4337 infrastructure (bundlers, EntryPoint, etc.), withdraw the account's native token balance, and so on.
### Assigning Admin Permissions
Existing admins on the account can add new admins, remove existing admins or renounce their own admin status.
<Tabs defaultValue="react">
<TabsList>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
</TabsList>
<TabsContent value="react">
```tsx
import { addAdmin } from "thirdweb/extensions/erc4337";
import { useSendTransaction, useActiveAccount } from "thirdweb/react";
import { getContract } from "thirdweb";
const { mutate: sendTransaction } = useSendTransaction();
const smartAccount = useActiveAccount();
const onClick = () => {
if (!smartAccount) return;
const transaction = addAdmin({
contract: getContract({
address: smartAccount.address,
chain,
client,
}),
account: smartAccount,
adminAddress: "0x...", // the address of the new admin
});
sendTransaction(transaction);
};
```
</TabsContent>
<TabsContent value="typescript">
```typescript
import { addAdmin } from "thirdweb/extensions/erc4337";
import { smartWallet } from "thirdweb/wallets";
import { sendTransaction, getContract } from "thirdweb";
const wallet = new smartWallet(config);
const smartAccount = await wallet.connect({
client,
personalAccount,
});
const transaction = addAdmin({
contract: getContract({
address: smartAccount.address,
chain,
client,
}),
account: smartAccount,
adminAddress: "0x...", // the address of the new admin
});
await sendTransaction({
transaction,
account: smartAccount,
});
```
</TabsContent>
</Tabs>
## 2. Session Keys
Session Keys are additional authorized signers that must go through ERC-4337 infrastructure (bundlers, EntryPoint, etc.) to use an account to execute transactions. Session keys can use an account under certain restrictions.
### Assigning Session Key Permissions
Each individual session key has its own permissions to use the account. Only admins can set the permissions for session keys.
Session keys can be assigned the following permissions:
- [Required] Allow interaction with specific contracts with the account ("*" for any contracts)
- [Optional] Have a maximum amount of native tokens that can be transferred per transaction (defaults to 0 eth, transactions with value will be rejected)
- [Optional] Have access to the account only during a specific time window (defaults to 10 years from now)
These restrictions are set in the `SignerPermissionsRequest` struct.
To set the permissions for a given signer, the `setPermissionsForSigner` function is called.
<Tabs defaultValue="react">
<TabsList>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
<TabsTrigger value="unity">Unity</TabsTrigger>
</TabsList>
<TabsContent value="react">
```tsx
import { addSessionKey } from "thirdweb/extensions/erc4337";
import { useSendTransaction, useActiveAccount } from "thirdweb/react";
import { getContract } from "thirdweb";
const { mutate: sendTransaction } = useSendTransaction();
const smartAccount = useActiveAccount();
const onClick = () => {
if (!smartAccount) return;
const transaction = addSessionKey({
contract: getContract({
address: smartAccount.address,
chain,
client,
}),
account: smartAccount,
sessionKeyAddress: "0x...", // the address of the account allowed to use the session key
permissions: {
approvedTargets: "*", // the addresses of allowed contracts, or '*' for any contract
nativeTokenLimitPerTransaction: 0.1, // the maximum amount of native token (in ETH) that the session key can spend per transaction
permissionStartTimestamp: new Date(), // the date when the session key becomes active
permissionEndTimestamp: new Date(Date.now() + 24 * 60 * 60 * 1000), // the date when the session key expires
},
});
sendTransaction(transaction);
};
```
</TabsContent>
<TabsContent value="typescript">
```ts
import { addSessionKey } from "thirdweb/extensions/erc4337";
import { wallet } from "thirdweb/wallets";
import { sendTransaction, getContract } from "thirdweb";
const wallet = new smartWallet(config);
const smartAccount = await smartWallet.connect({
client,
personalAccount,
});
const transaction = addSessionKey({
contract: getContract({
address: smartAccount.address,
chain,
client,
}),
account: smartAccount,
sessionKeyAddress: "0x...", // the address of the new session key
permissions: {
approvedTargets: "*", // the addresses of allowed contracts, or '*' for any contract
nativeTokenLimitPerTransaction: 0.1, // the maximum amount of native token (in ETH) that the session key can spend per transaction
permissionStartTimestamp: new Date(), // the date when the session key becomes active
permissionEndTimestamp: new Date(Date.now() + 24 * 60 * 60 * 1000), // the date when the session key expires
},
});
await sendTransaction({
transaction,
account: smartAccount,
});
```
</TabsContent>
<TabsContent value="unity">
```csharp
using Thirdweb;
// Reference to your Thirdweb SDK
var sdk = ThirdwebManager.Instance.SDK;
// Create a session key
string signerAddress = "0xSignerAddress";
List<string> approvedTargets = new List<string> { "0xTargetAddress" };
string nativeTokenLimitPerTransactionInWei = "0";
string permissionStartTimestamp = "0";
string permissionEndTimestamp = Utils.GetUnixTimestampIn10Years().ToString();
string reqValidityStartTimestamp = "0";
string reqValidityEndTimestamp = Utils.GetUnixTimestampIn10Years().ToString();
await sdk.wallet.CreateSessionKey(
"0xSignerAddress",
approvedTargets,
nativeTokenLimitPerTransactionInWei,
permissionStartTimestamp,
permissionEndTimestamp,
reqValidityStartTimestamp,
reqValidityEndTimestamp
);
```
</TabsContent>
</Tabs>
@@ -1,89 +0,0 @@
import SponsorshipRulesImg from "./sponsorship-rules.png";
import { Callout, DocImage, createMetadata } from "@doc";
export const metadata = createMetadata({
image: {
title: "Sponsorship rules",
icon: "thirdweb",
},
title: "Sponsorship rules | thirdweb",
description:
"Set rules around which smart account transactions (User Ops) the paymaster should sponsor",
});
# Sponsorship rules
You can easily define rules around which transactions (User Ops) the paymaster should sponsor. This can be used to prevent abuse, progressively onboard users, and incentivize specific in-app actions.
You can set the following sponsorship rules
- **Global spend limits**: Maximum gas cost (in USD) that you want to sponsor. This applies for the duration of the billing period (monthly).
- **Server verifier**: This is a general-purpose check that can be used to define custom rules. Before a transaction is sent, it checks with an endpoint that you specify whether it should be sponsored or not.
- **Contract address restrictions**: Use this if you want to sponsor transactions sent only to a specific set of contracts. Transactions sent to contracts outside this allowlist will remain unsponsored.
- **Chain restrictions**: Use this if you want to sponsor transactions only on certain chains. If this is not set, transactions on all [supported chains](https://portal.thirdweb.com/wallets/account-abstraction/infrastructure#supported-chains) will be sponsored.
- **User lists**: You can use this define allowlists or blocklists. Allowlists let you restrict sponsored transactions to transactions sent by a specific set of known accounts. Blocklists let you block bad actors from abusing your paymaster.
- **Admin accounts**: These accounts are not subject to any sponsorship rule, all their transactions will be sponsored. Use this only with accounts that you control (eg. for testing purposes).
We highly recommend that you set sponsorship rules before your app goes live to prevent malicious actors from abusing your paymaster, which could lead to unexpectedly large bills.
### Setting Sponsorship rules
You can easily set sponsorship rules by going to your Team > Project > Connect > Account Abstraction > Sponsorship Policies page.
<DocImage src={SponsorshipRulesImg} />
### Setting up a server verifier
You can set up a server verifier by providing a URL that the paymaster will call before sponsoring a transaction. This allows for fine grained control over which transactions are sponsored.
The thirdweb infrastructure will send a POST request to the URL you specify with the following JSON payload:
```ts
// thirdweb will call your server with the following JSON body as a POST request
{
clientId: string; // the clientId that is making the request
chainId: number; // the chainId of the transaction
userOp: {
sender: string; // the address of the sender
targets: string[]; // the addresses of the target contracts or wallets
gasLimit: string; // the gas limit of the transaction
gasPrice: string; // the gas price of the transaction in wei
data: {
targets: string[], // the targets contracts or wallets called in this operation
callDatas: string[], // the call data of each transaction
values: string[], // the values (in wei) of each transaction
}
};
}
```
Your server should respond with a 200 and the following JSON payload:
```ts
// You should respond with the following JSON payload
{
isAllowed: boolean; // whether the transaction should be sponsored or not
reason: string?; // optional reason for why the transaction is not allowed
}
```
Note: for performance reasons, your server should respond within 5 seconds. If it takes longer, the transaction will be unsponsored.
### FAQs
**What happens if a limit is hit? Does the transaction fail or does it go unsponsored?**
If a limit is hit the transaction will go unsponsored. If you are spinning up fresh accounts for users in your app, it is unlikely that the user accounts will hold any funds. You can catch the following out of funds error to gracefully handle this within your app `AA21 didn't pay prefund`.
**What networks are sponsorship rules available in?**
Sponsorship rules apply across all the [networks that we support account abstraction in](https://portal.thirdweb.com/connect/account-abstraction/infrastructure#supported-chains). You don't need to specify different rules for each network or each account factory as long as they use the same client ID.
However, you can restrict sponsored transactions to specific networks if you choose to.
**Will sponsorship rules work with third-party paymasters?**
No, sponsorship rules will apply if you are using thirdweb's paymaster.
### Pricing
Setting a server verifier requires your account to be on the growth plan. The other policies are free to use. There is no usage based pricing for policies.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

@@ -1,51 +0,0 @@
import { Stack, ArticleIconCard } from "@doc";
import { TypeScriptIcon, ReactIcon, UnityIcon, UnrealEngineIcon, DotNetIcon } from "@/icons";
# Blockchain API
Perfomant, reliable and type safe API to read write to any contract on any EVM chain through our [RPC Edge](/infrastructure/rpc-edge/overview) endpoints.
<Stack>
<ArticleIconCard
title="TypeScript"
icon={TypeScriptIcon}
href="/typescript/v5"
description="Core TypeScript API"
/>
<ArticleIconCard
title="React"
icon={ReactIcon}
href="/react/v5"
description="React components and hooks"
/>
<ArticleIconCard
title="React Native"
icon={ReactIcon}
href="/react-native/v5"
description="React Native components and hooks"
/>
<ArticleIconCard
title=".NET"
icon={DotNetIcon}
href="/dotnet"
description="Core C# API for .NET applications"
/>
<ArticleIconCard
title="Unity"
icon={UnityIcon}
href="/unity"
description="Unity scripts and prefabs"
/>
<ArticleIconCard
title="Unreal Engine"
icon={UnrealEngineIcon}
href="/unreal-engine"
description="Unreal blueprints and C++ API"
/>
</Stack>
@@ -1,255 +0,0 @@
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
Steps,
Step,
DocImage,
Callout,
createMetadata,
} from "@doc";
import EWCreateKey from "../images/ew-create-key.png";
import EWCustomAuthConfig from "../images/ew-custom-auth-config.png";
import EWCustomAuthFlow from "../images/ew-custom-auth-flow.png";
export const metadata = createMetadata({
image: {
title: "Custom Auth Server for In-App Wallet",
icon: "wallets",
},
title: "Custom Auth Server | InAppWallet",
description:
"Learn how to integrate your auth backend with our in-app wallets solution so you can onboard your users into web3 seamlessly.",
});
# Custom Auth Server
Learn how to integrate your auth backend with our in-app wallets solution so you can onboard your users into web3 seamlessly.
This guide will show you how to create your own Auth Server that is compatible with the `auth_endpoint` strategy. By doing so, you can have full control over user authentication and data security. This allows you to ensure that your application meets specific compliance requirements while also providing a customized sign-in experience.
<Callout variant="warning" title="Security">
This guide is simplified for demonstration purposes and is not ready for
production use. When modifying it for production, secure your endpoints and
avoid hard-coding secrets or sensitive information. We recommend using
environment variables and secret managers.
</Callout>
## 5 minute quickstart
<Steps>
<Step title="Step 1">
Navigate to Your Team > Project > Connect > In-App Wallets Page from the (dashboard)[https://thirdweb.com/team]
</Step>
<Step title="Step 2">
Create a thirdweb API key if you don't have one or select an existing key to
use for this project. [Learn more about API
keys.](/api-keys)
<DocImage src={EWCreateKey} />
</Step>
<Step title="Step 3">
Allowlist domain or bundle IDs in Access Restrictions.
</Step>
<Step title="Step 4">
Navigate to the Configuration view and enable **Custom Auth Endpoint**
<DocImage src={EWCustomAuthConfig} />
</Step>
<Step title="Step 5">
Set the Auth Endpoint URL to
`https://embedded-wallet.thirdweb.com/api/2023-11-30/embedded-wallet/auth/test-custom-auth-endpoint`
for testing purposes. You will replace this later with your own auth server
endpoint to verify the `payload`.
</Step>
<Step title="Step 6">Save the configuration.</Step>
<Step title="Step 7">Copy the client ID.</Step>
<Step title="Step 8">
In your preferred thirdweb client SDK, pass the payload you retrieved from
logging in to the server.
</Step>
</Steps>
You can now auth into the wallet and use it to sign transactions like so (see [use your own auth for more](/in-app-wallet/custom-auth)):
<Tabs defaultValue="react">
<TabsList>
<TabsTrigger value="react">React & React Native</TabsTrigger>
<TabsTrigger value="typescript">Other Frameworks</TabsTrigger>
</TabsList>
<TabsContent value="react">
```typescript
import { inAppWallet } from "thirdweb/wallets";
import { useConnect } from "thirdweb/react";
const { connect } = useConnect();
const handlePostLogin = async (jwt: string) => {
await connect(() => {
const wallet = inAppWallet();
wallet.connect({
client,
strategy: "auth_endpoint",
payload: JSON.stringify({ userId: "ANY_RANDOM_ID_HERE" }),
});
return wallet;
});
};
```
</TabsContent>
<TabsContent value="typescript">
```typescript
import { inAppWallet } from "thirdweb/wallets";
const wallet = inAppWallet();
const account = await wallet.connect({
client,
strategy: "auth_endpoint",
payload: JSON.stringify({ userId: "ANY_RANDOM_ID_HERE" }),
});
// use the account to send transactions
```
</TabsContent>
</Tabs>
A persistent, cross-platform wallet is now created for your user!
Of course, you would use your own auth server instead of the one we provided. The rest of this guide will show you how to create your own auth server.
### Setup
The following steps will show you how to create a simple auth server that can be used with the embedded wallet.
At a high level, the auth server will:
1. Handle login for the user into your application.
2. Have a way to get a public identifier for the user.
3. Have an endpoint to verify the public identifier and return some basic information about the user
Steps 1 and 2 are up to you to implement. You can use any auth strategy you want.
The endpoint in step 3 is what you register as your auth endpoint on the thirdweb dashboard.
Here's a high-level diagram:
<DocImage src={EWCustomAuthFlow} />
<Steps>
<Step title="Step 1">
Create a new directory for your project and navigate to it in your CLI
```bash
mkdir custom-auth-server
cd custom-auth-server
```
</Step>
<Step title="Step 2">
Initialize a new Node.js application
```bash
npm init -y
```
OR
```bash
yarn init -y
```
</Step>
</Steps>
### **Create the Server:**
In the `custom-auth-server` directory, create a file at the root named `server.js` and paste the following:
```jsx
const express = require("express");
const fs = require("fs");
const app = express();
const PORT = process.env.PORT || 3000;
const users = [{ id: 1, email: "[email protected]", password: "password123" }];
app.use(express.json());
// This is what your app calls to log in a user and get a public identifier for the user (otherwise known as the payload)
app.post("/login", (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email && u.password === password);
if (!user) return res.status(401).send({ message: "Invalid credentials" });
res.send({ payload: user.id });
});
// This is a sample endpoint that you would register on the thirdweb dashboard for us to verify the payload
app.post("/thirdweb-will-call-this", (req, res) => {
const { payload } = req.body;
if (!payload) return res.status(401).send({ message: "Invalid credentials" });
// You would write your own logic here to verify the payload here
const user = users.find((u) => u.id === payload);
if (!user) return res.status(401).send({ message: "Invalid credentials" });
// Once the user is successfully verified, you can return the following field
return res.send({
userId: user.id,
// The last two fields here are optional
email: user.email,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
});
});
app.listen(PORT, () => {
console.log(`Server started on port ${PORT}`);
});
```
### **Test Locally**
1. Start the server:
```bash
node server.js
```
2. Test login:
```bash
curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"email": "[email protected]", "password": "password123"}'
```
### **Deploy**
To deploy the server, you can use services such as [Zeet](https://zeet.co/) or [Docker](https://www.docker.com/).
### **Integrate In-App Wallets**
Refer top the [quickstart above](#5-minute-quickstart) to integrate the embedded wallet into your application.

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