Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5345808ec | ||
|
|
bfa0a42219 | ||
|
|
426bb8bbb2 | ||
|
|
823af013ae | ||
|
|
98aea1e0b9 | ||
|
|
620e294f8c | ||
|
|
84eaded152 | ||
|
|
31d7173432 | ||
|
|
a48329b697 | ||
|
|
6e62a3aa5c | ||
|
|
746b0c9d8b | ||
|
|
0f38a1287a | ||
|
|
61bfebf5d8 | ||
|
|
170c377445 | ||
|
|
4cbf67181d | ||
|
|
6bf6108beb | ||
|
|
ce85be3346 | ||
|
|
e125e743a3 |
@@ -448,6 +448,11 @@ async function redirects() {
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
{
|
||||
source: "/support/:path*",
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
-67
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-66
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-17
@@ -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>
|
||||
);
|
||||
};
|
||||
-58
@@ -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>
|
||||
);
|
||||
};
|
||||
-27
@@ -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>
|
||||
);
|
||||
};
|
||||
-208
@@ -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,
|
||||
};
|
||||
}
|
||||
-183
@@ -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>
|
||||
|
||||
+328
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
+307
@@ -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>
|
||||
);
|
||||
}
|
||||
+44
@@ -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>
|
||||
);
|
||||
}
|
||||
+255
@@ -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>
|
||||
);
|
||||
}
|
||||
+57
@@ -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>
|
||||
);
|
||||
}
|
||||
+172
@@ -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>
|
||||
);
|
||||
}
|
||||
+34
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+14
-11
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
+16
-19
@@ -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 (
|
||||
+14
-15
@@ -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 (
|
||||
<>
|
||||
+10
-10
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
+36
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+55
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+27
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+16
-10
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
+3
-5
@@ -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}
|
||||
+11
-6
@@ -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
|
||||
+2
@@ -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";
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
+1
-1
@@ -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} />
|
||||
|
||||
+3
-3
@@ -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}`
|
||||
|
||||
+3
-3
@@ -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));
|
||||
|
||||
|
||||
+7
-4
@@ -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>
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
+29
-21
@@ -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,
|
||||
}: {
|
||||
|
||||
+2
@@ -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}
|
||||
|
||||
+2
@@ -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}
|
||||
|
||||
+466
-594
File diff suppressed because it is too large
Load Diff
+123
-59
@@ -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>
|
||||
|
||||
+84
-3
@@ -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}
|
||||
|
||||
+2593
-247
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
BIN
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
Reference in New Issue
Block a user