Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5345808ec | ||
|
|
bfa0a42219 | ||
|
|
426bb8bbb2 | ||
|
|
823af013ae | ||
|
|
98aea1e0b9 | ||
|
|
620e294f8c | ||
|
|
84eaded152 | ||
|
|
31d7173432 | ||
|
|
a48329b697 | ||
|
|
6e62a3aa5c | ||
|
|
746b0c9d8b | ||
|
|
0f38a1287a | ||
|
|
61bfebf5d8 | ||
|
|
170c377445 | ||
|
|
4cbf67181d | ||
|
|
6bf6108beb | ||
|
|
ce85be3346 | ||
|
|
e125e743a3 | ||
|
|
05da842b7a | ||
|
|
216b70475e | ||
|
|
9d259d0836 | ||
|
|
ca52cdd3bf | ||
|
|
cf68002922 | ||
|
|
495c7f925d | ||
|
|
32add5f602 | ||
|
|
5f74b3ca3e | ||
|
|
761cde6a32 | ||
|
|
9546b927d9 | ||
|
|
f82f885dad | ||
|
|
34d2fddf49 | ||
|
|
a7544f75ce | ||
|
|
bd3d645be6 | ||
|
|
5fedc2c040 | ||
|
|
6c8a7cf2ba | ||
|
|
4f02f6eeb7 | ||
|
|
72f75ea40b |
@@ -62,4 +62,6 @@ module.exports = [
|
||||
// -- universal bridge landing pages --
|
||||
"/universal-bridge-regions/:region_slug",
|
||||
"/enterprise",
|
||||
"/token",
|
||||
"/vault",
|
||||
];
|
||||
|
||||
@@ -448,6 +448,11 @@ async function redirects() {
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
{
|
||||
source: "/support/:path*",
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { isVercel } from "@/utils/vercel";
|
||||
import { verifyTurnstileToken } from "./verifyTurnstileToken";
|
||||
import { verifyTurnstileToken } from "../../app/login/verifyTurnstileToken";
|
||||
|
||||
export async function getLoginPayload(
|
||||
params: GenerateLoginPayloadParams,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ type WebhookConfigsResponse =
|
||||
};
|
||||
|
||||
interface CreateWebhookConfigRequest {
|
||||
topics: { id: string; filters: object | null }[];
|
||||
topicIdsWithFilters: { id: string; filters: object | null }[];
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
isPaused?: boolean;
|
||||
@@ -76,7 +76,7 @@ type TopicsResponse =
|
||||
|
||||
interface UpdateWebhookConfigRequest {
|
||||
destinationUrl?: string;
|
||||
topics?: { id: string; filters: object | null }[];
|
||||
topicIdsWithFilters?: { id: string; filters: object | null }[];
|
||||
description?: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
@@ -310,3 +310,53 @@ export async function deleteWebhookConfig(props: {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
type TestDestinationUrlResponse =
|
||||
| {
|
||||
result: {
|
||||
httpStatusCode: number;
|
||||
httpResponseBody: string;
|
||||
};
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export async function testDestinationUrl(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
destinationUrl: string;
|
||||
}): Promise<TestDestinationUrlResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/test-destination-url`,
|
||||
{
|
||||
body: JSON.stringify({ destinationUrl: props.destinationUrl }),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return {
|
||||
body: await resp.text(),
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
|
||||
import { CrownIcon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
@@ -111,18 +111,15 @@ export function UpsellContent(props: {
|
||||
<CardContent className="space-y-6">
|
||||
{props.benefits && props.benefits.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
|
||||
What you'll get:
|
||||
<h4 className="font-semibold text-foreground text-sm capitalize text-center">
|
||||
What you'll get
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
{props.benefits.map((benefit) => (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
className="flex items-center justify-center gap-3 text-center text-balance"
|
||||
key={benefit.description}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
|
||||
<SparklesIcon className="h-3 w-3 text-success-text" />
|
||||
</div>
|
||||
<span className="text-sm">{benefit.description}</span>
|
||||
{benefit.status === "soon" && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
|
||||
@@ -116,13 +116,12 @@ export function ChatBar(props: {
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Send"
|
||||
className="!h-auto w-auto border border-nebula-pink-foreground p-2 disabled:opacity-100"
|
||||
className="!h-auto w-auto p-2 disabled:opacity-100"
|
||||
disabled={message.trim() === "" || props.isConnectingWallet}
|
||||
onClick={() => {
|
||||
if (message.trim() === "") return;
|
||||
handleSubmit(message);
|
||||
}}
|
||||
variant="pink"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -14,22 +16,22 @@ const client = createThirdwebClient({
|
||||
});
|
||||
|
||||
export function CustomChatButton(props: {
|
||||
isLoggedIn: boolean;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
isFloating: boolean;
|
||||
pageType: "chain" | "contract" | "support";
|
||||
label: string;
|
||||
examplePrompts: string[];
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
authToken: string;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
const layoutSegments = useSelectedLayoutSegments();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasBeenOpened, setHasBeenOpened] = useState(false);
|
||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (layoutSegments[0] === "~" && layoutSegments[1] === "support") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inline Button (not floating) */}
|
||||
@@ -54,14 +56,14 @@ export function CustomChatButton(props: {
|
||||
ref={ref}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-lg">
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
{props.label}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close chat"
|
||||
className="h-auto w-auto p-1 text-muted-foreground"
|
||||
className="h-auto w-auto p-1 text-muted-foreground rounded-full"
|
||||
onClick={closeModal}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -80,9 +82,7 @@ export function CustomChatButton(props: {
|
||||
message: prompt,
|
||||
title: prompt,
|
||||
}))}
|
||||
networks={props.networks}
|
||||
requireLogin={props.requireLogin}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
"use client";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
import type { UserMessage, UserMessageContent } from "./CustomChats";
|
||||
import { type CustomChatMessage, CustomChats } from "./CustomChats";
|
||||
import type { ExamplePrompt, NebulaContext } from "./types";
|
||||
import type { ExamplePrompt } from "./types";
|
||||
|
||||
export default function CustomChatContent(props: {
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
networks: NebulaContext["networks"];
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
if (props.requireLogin !== false && !props.authToken) {
|
||||
return <LoggedOutStateChatContent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomChatContentLoggedIn
|
||||
authToken={props.authToken || ""}
|
||||
client={props.client}
|
||||
clientId={props.clientId}
|
||||
examplePrompts={props.examplePrompts}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomChatContentLoggedIn(props: {
|
||||
authToken: string;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
@@ -55,6 +47,9 @@ function CustomChatContentLoggedIn(props: {
|
||||
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
|
||||
const connectionStatus = useActiveWalletConnectionStatus();
|
||||
|
||||
const [showSupportForm, setShowSupportForm] = useState(false);
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (userMessage: UserMessage) => {
|
||||
const abortController = new AbortController();
|
||||
@@ -96,7 +91,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
...(props.clientId ? { "x-client-id": props.clientId } : {}),
|
||||
},
|
||||
method: "POST",
|
||||
@@ -132,7 +127,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
setEnableAutoScroll(false);
|
||||
}
|
||||
},
|
||||
[props.authToken, props.clientId, props.teamId, sessionId],
|
||||
[props.authToken, props.clientId, props.team.id, sessionId],
|
||||
);
|
||||
|
||||
const handleFeedback = useCallback(
|
||||
@@ -165,7 +160,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -188,9 +183,20 @@ function CustomChatContentLoggedIn(props: {
|
||||
// Consider implementing retry logic here
|
||||
}
|
||||
},
|
||||
[sessionId, props.authToken, props.teamId, messages],
|
||||
[sessionId, props.authToken, props.team.id, messages],
|
||||
);
|
||||
|
||||
const handleAddSuccessMessage = useCallback((message: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "assistant" as const,
|
||||
text: message,
|
||||
request_id: undefined,
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
@@ -212,6 +218,12 @@ function CustomChatContentLoggedIn(props: {
|
||||
sessionId={sessionId}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
useSmallText
|
||||
showSupportForm={showSupportForm}
|
||||
setShowSupportForm={setShowSupportForm}
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
team={props.team}
|
||||
addSuccessMessage={handleAddSuccessMessage}
|
||||
/>
|
||||
)}
|
||||
<ChatBar
|
||||
@@ -245,42 +257,6 @@ function CustomChatContentLoggedIn(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function LoggedOutStateChatContent() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center p-4">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
today?
|
||||
</h1>
|
||||
|
||||
<div className="h-3" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
Sign in to use AI Assistant
|
||||
</p>
|
||||
<div className="h-5" />
|
||||
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="w-full max-w-96 gap-2"
|
||||
href={`/login?next=${encodeURIComponent(pathname)}`}
|
||||
>
|
||||
Sign in
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
examplePrompts: { title: string; message: string }[];
|
||||
@@ -288,9 +264,12 @@ function EmptyStateChatPageContent(props: {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-auto p-4 ">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/20 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-1">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/40 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-2">
|
||||
<NebulaIcon className="size-7 text-nebula-pink-foreground" />
|
||||
<div className="rounded-full border p-1 bg-muted/20">
|
||||
<div className="rounded-full border p-2 bg-inverted">
|
||||
<ThirdwebMiniLogo
|
||||
isMonoChrome
|
||||
className="size-7 text-inverted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,11 +280,12 @@ function EmptyStateChatPageContent(props: {
|
||||
</h1>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="flex max-w-lg flex-col flex-wrap justify-center gap-2.5">
|
||||
<div className="flex flex-col items-center justify-center gap-2.5 overflow-hidden">
|
||||
{props.examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
disabled={false}
|
||||
key={prompt.title}
|
||||
className="rounded-full text-xs sm:text-sm truncate bg-card w-fit h-auto py-1.5 whitespace-pre-wrap"
|
||||
onClick={() =>
|
||||
props.sendMessage({
|
||||
content: [
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
MessageCircleIcon,
|
||||
ArrowRightIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Reasoning } from "./Reasoning";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm";
|
||||
import { Button } from "../ui/button";
|
||||
import { TextShimmer } from "../ui/text-shimmer";
|
||||
|
||||
// Define local types
|
||||
export type UserMessageContent = { type: "text"; text: string };
|
||||
@@ -46,11 +57,18 @@ export function CustomChats(props: {
|
||||
enableAutoScroll: boolean;
|
||||
useSmallText?: boolean;
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
showSupportForm: boolean;
|
||||
setShowSupportForm: (v: boolean) => void;
|
||||
productLabel: string;
|
||||
setProductLabel: (v: string) => void;
|
||||
team: Team;
|
||||
addSuccessMessage?: (message: string) => void;
|
||||
}) {
|
||||
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [supportTicketCreated, setSupportTicketCreated] = useState(false);
|
||||
|
||||
// auto scroll to bottom when messages change
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@@ -86,6 +104,8 @@ export function CustomChats(props: {
|
||||
chatScrollContainer.addEventListener("wheel", disableScroll);
|
||||
}, [setEnableAutoScroll, enableAutoScroll]);
|
||||
|
||||
const [showSupportFormDialog, setShowSupportFormDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex max-h-full flex-1 flex-col overflow-hidden"
|
||||
@@ -123,6 +143,66 @@ export function CustomChats(props: {
|
||||
sendMessage={props.sendMessage}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
{/* Support Case Button/Form in last assistant message */}
|
||||
{message.type === "assistant" &&
|
||||
index === props.messages.length - 1 && (
|
||||
<>
|
||||
{/* Only show button/form if ticket not created */}
|
||||
{!supportTicketCreated && (
|
||||
<div className="mt-3 pl-12">
|
||||
<Dialog
|
||||
open={showSupportFormDialog}
|
||||
onOpenChange={setShowSupportFormDialog}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setShowSupportFormDialog(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full bg-card"
|
||||
>
|
||||
Create Support Case
|
||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<DialogContent className="p-0 bg-card">
|
||||
<DynamicHeight>
|
||||
<DialogHeader className="p-4 lg:p-6 border-b border-dashed space-y-1">
|
||||
<DialogTitle className="text-xl font-semibold text-foreground tracking-tight">
|
||||
Create Support Case
|
||||
</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Let's create a detailed support case for
|
||||
our technical team.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<SupportTicketForm
|
||||
team={props.team}
|
||||
productLabel={props.productLabel}
|
||||
setProductLabel={props.setProductLabel}
|
||||
conversationId={props.sessionId}
|
||||
closeForm={() => {
|
||||
setShowSupportFormDialog(false);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
props.setShowSupportForm(false);
|
||||
props.setProductLabel("");
|
||||
setSupportTicketCreated(true);
|
||||
// Add success message as a regular assistant message
|
||||
if (props.addSuccessMessage) {
|
||||
const supportPortalUrl = `/team/${props.team.slug}/~/support`;
|
||||
props.addSuccessMessage(
|
||||
`Your support ticket has been created! Our team will get back to you soon. You can also visit the [support portal](${supportPortalUrl}) to track your case.`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DynamicHeight>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -143,7 +223,7 @@ function RenderMessage(props: {
|
||||
nextMessage: CustomChatMessage | undefined;
|
||||
authToken: string;
|
||||
sessionId: string | undefined;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
}) {
|
||||
const { message } = props;
|
||||
|
||||
@@ -179,13 +259,16 @@ function RenderMessage(props: {
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full",
|
||||
message.type === "assistant" && "border bg-card",
|
||||
message.type === "assistant" && "border bg-inverted",
|
||||
message.type === "error" && "border",
|
||||
message.type === "presence" && "border bg-card",
|
||||
message.type === "presence" && "border bg-inverted",
|
||||
)}
|
||||
>
|
||||
{(message.type === "presence" || message.type === "assistant") && (
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
<ThirdwebMiniLogo
|
||||
className="size-4 text-inverted-foreground"
|
||||
isMonoChrome
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.type === "error" && (
|
||||
@@ -227,7 +310,7 @@ function RenderMessage(props: {
|
||||
function CustomFeedbackButtons(props: {
|
||||
message: CustomChatMessage & { type: "assistant" };
|
||||
messageIndex: number;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -240,9 +323,9 @@ function CustomFeedbackButtons(props: {
|
||||
await props.onFeedback(props.messageIndex, feedback);
|
||||
} catch (_e) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
// Don't show buttons if feedback already given
|
||||
@@ -251,25 +334,26 @@ function CustomFeedbackButtons(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", props.className)}>
|
||||
<button
|
||||
aria-label="Thumbs up"
|
||||
className="text-muted-foreground transition-colors hover:text-green-500 disabled:opacity-50"
|
||||
<div className={cn("flex gap-1.5", props.className)}>
|
||||
<Button
|
||||
aria-label="Upvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsUpIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Thumbs down"
|
||||
className="text-muted-foreground transition-colors hover:text-red-500 disabled:opacity-50"
|
||||
<ThumbsUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Downvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(-1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsDownIcon className="size-5" />
|
||||
</button>
|
||||
<ThumbsDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,7 +380,11 @@ function RenderResponse(props: {
|
||||
);
|
||||
|
||||
case "presence":
|
||||
return <Reasoning isPending={isMessagePending} texts={message.texts} />;
|
||||
return (
|
||||
<div className="h-8 flex items-center">
|
||||
<TextShimmer text="Reasoning..." />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
@@ -325,19 +413,15 @@ function StyledMarkdownRenderer(props: {
|
||||
}) {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
className="[&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
code={{
|
||||
className: "bg-transparent",
|
||||
className: "bg-card",
|
||||
ignoreFormattingErrors: true,
|
||||
}}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
li={{ className: "text-foreground" }}
|
||||
markdownText={props.text}
|
||||
p={{
|
||||
className:
|
||||
props.type === "assistant"
|
||||
? "text-foreground"
|
||||
: "text-foreground leading-normal",
|
||||
className: props.type === "assistant" ? "" : "leading-normal",
|
||||
}}
|
||||
skipHtml
|
||||
/>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { TextShimmer } from "@/components/ui/text-shimmer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Reasoning(props: { isPending: boolean; texts: string[] }) {
|
||||
const [_isOpen, setIsOpen] = useState(false);
|
||||
const isOpen = props.isPending ? true : _isOpen;
|
||||
const showAll = !props.isPending;
|
||||
const lastText = props.texts[props.texts.length - 1];
|
||||
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<Button
|
||||
className="h-auto w-auto items-center gap-1 px-0 py-1 pb-2 font-normal text-base text-muted-foreground hover:text-foreground hover:no-underline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<TextShimmer text="Reasoning..." />
|
||||
) : (
|
||||
<span className="text-foreground">Reasoning</span>
|
||||
)}
|
||||
|
||||
{!props.isPending && (
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{showAll && props.texts.length > 0 && (
|
||||
<ul className="list-none space-y-1.5">
|
||||
{props.texts.map((text) => (
|
||||
<li
|
||||
className="fade-in-0 animate-in text-muted-foreground text-sm leading-relaxed duration-300"
|
||||
key={text}
|
||||
>
|
||||
{text.trim()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAll && lastText && (
|
||||
<div className="text-muted-foreground text-sm leading-relaxed">
|
||||
{lastText.trim()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,6 @@ export type ExamplePrompt = {
|
||||
|
||||
// TODO - remove "Nebula" wording and simplify types
|
||||
|
||||
export type NebulaContext = {
|
||||
chainIds: string[] | null;
|
||||
walletAddress: string | null;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
};
|
||||
|
||||
type NebulaUserMessageContentItem =
|
||||
| {
|
||||
type: "image";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
|
||||
import { doLogout } from "@app/login/auth-actions";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
useActiveAccount,
|
||||
useConnectModal,
|
||||
} from "thirdweb/react";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
|
||||
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBannerUI
|
||||
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
label="We have re-branded our Engine, Universal Bridge, and Connect products. Please read the full blog post for details on changes"
|
||||
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
trackingLabel="product-rebrand"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,6 @@ const buttonVariants = cva(
|
||||
link: "underline-offset-4 hover:underline",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
|
||||
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
|
||||
secondary:
|
||||
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
|
||||
|
||||
@@ -150,9 +150,13 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button asChild className="py-2 text-sm" variant="primary">
|
||||
<Link href="/support" rel="noopener noreferrer" target="_blank">
|
||||
Visit support site
|
||||
<Button asChild className="py-2 text-sm" variant="default">
|
||||
<Link
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Contact Support
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
},
|
||||
nebula: {
|
||||
enabled: true,
|
||||
rateLimit: 1000,
|
||||
rateLimit: {
|
||||
perSecond: 1000,
|
||||
perMinute: 1000,
|
||||
},
|
||||
},
|
||||
pay: {
|
||||
enabled: true,
|
||||
@@ -82,6 +85,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
totalFileSizeBytesLimit: 1_000_000_000,
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
enabled: true,
|
||||
rateLimit: 10,
|
||||
},
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
dedicatedSupportChannel: null,
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { JSX } from "react";
|
||||
|
||||
const PLEASE_REACH_OUT_MESSAGE = (
|
||||
<span>
|
||||
If you believe this is incorrect or the error persists, please visit our{" "}
|
||||
If you believe this is incorrect or the error persists, please{" "}
|
||||
<Link
|
||||
className="font-semibold underline"
|
||||
href="/support"
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
support site
|
||||
contact support
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Universal Bridge Routes: Swap, Bridge, and Onramp";
|
||||
const title = "Payments Routes: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
@@ -66,7 +66,7 @@ export default async function RoutesPage(props: {
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with Universal Bridge
|
||||
Get Started with thirdweb Payments
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and chain.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import type { Address } from "thirdweb";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import type { Route } from "./types/route";
|
||||
@@ -10,21 +9,13 @@ export async function getRoutes({
|
||||
offset,
|
||||
originQuery,
|
||||
destinationQuery,
|
||||
originChainId,
|
||||
destinationChainId,
|
||||
originTokenAddress,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
destinationChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes`);
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
@@ -37,20 +28,7 @@ export async function getRoutes({
|
||||
if (destinationQuery) {
|
||||
url.searchParams.set("destinationQuery", destinationQuery);
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
url.searchParams.set("sortBy", "popularity");
|
||||
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../../../../@/utils/map-chains";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { ChainIcon } from "../../../../components/server/chain-icon";
|
||||
import { AddChainToWallet } from "../client/add-chain-to-wallet";
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../../@/utils/map-chains";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { getAddress, getContract, isAddress } from "thirdweb";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token";
|
||||
import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides";
|
||||
|
||||
export async function getContractPageParamsInfo(params: {
|
||||
contractAddress: string;
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
|
||||
import { getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import type { ProjectMeta } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types";
|
||||
import { redirectToContractLandingPage } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
|
||||
import { buildContractPagePath } from "../../_utils/contract-page-path";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
|
||||
import { getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getPublishedByCardProps, PublishedByUI } from "./published-by-ui";
|
||||
|
||||
interface PublishedByProps {
|
||||
|
||||
+1
-1
@@ -39,8 +39,8 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
import { getSDKTheme } from "../../../../../../../../../../@/utils/sdk-component-theme";
|
||||
import { PublicPageConnectButton } from "../../../_components/PublicPageConnectButton";
|
||||
import { SupplyClaimedProgress } from "../../../_components/supply-claimed-progress";
|
||||
import { TokenPrice } from "../../../_components/token-price";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getAuthToken } from "../../../../../../@/api/auth-token";
|
||||
import {
|
||||
AllFilters,
|
||||
ChainOptionsFilter,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "../../../../../@/api/auth-token";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import {
|
||||
AllFilters,
|
||||
ChainOptionsFilter,
|
||||
|
||||
@@ -3,8 +3,8 @@ import "server-only";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fallbackChainIcon } from "@/utils/chain-icons";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { fallbackChainIcon } from "../../../../../../@/utils/chain-icons";
|
||||
|
||||
export async function ChainIcon(props: {
|
||||
iconUrl?: string;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { ContractPublishForm } from "@/components/contract-components/contract-publish-form";
|
||||
import { getActiveAccountCookie, getJWTCookie } from "@/constants/cookie";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
@@ -68,14 +68,15 @@ export default async function PublishContractPage(
|
||||
redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
|
||||
const userThirdwebClient = await getUserThirdwebClient({
|
||||
teamId: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-[1130px] flex-col gap-8 py-8">
|
||||
<ChakraProviderSetup>
|
||||
<ContractPublishForm
|
||||
client={getClientThirdwebClient({
|
||||
jwt: token,
|
||||
teamId: undefined,
|
||||
})}
|
||||
client={userThirdwebClient}
|
||||
isLoggedIn={!!token}
|
||||
onPublishSuccess={async () => {
|
||||
"use server";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { ErrorProvider } from "../../../@/contexts/error-handler";
|
||||
import { ErrorProvider } from "@/contexts/error-handler";
|
||||
|
||||
export default function DashboardLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { PublishedContract } from "@/components/contracts/published-contract";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { getAuthToken } from "../../../../../../@/api/auth-token";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { PublishedActions } from "../../components/contract-actions-published.client";
|
||||
import { DeployContractHeader } from "../../components/contract-header";
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { CustomContractForm } from "@/components/contract-components/contract-deploy-form/custom-contract";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
type DeployFormForUriProps = {
|
||||
contractMetadata: FetchDeployMetadataResult | null;
|
||||
|
||||
-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 { getTeamById } from "@/api/team";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../@/api/auth-token";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
|
||||
type State = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const UNTHREAD_API_KEY = process.env.UNTHREAD_API_KEY || "";
|
||||
|
||||
const planToCustomerId = {
|
||||
accelerate: process.env.UNTHREAD_ACCELERATE_TIER_ID as string,
|
||||
free: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
growth: process.env.UNTHREAD_GROWTH_TIER_ID as string,
|
||||
pro: process.env.UNTHREAD_PRO_TIER_ID as string,
|
||||
scale: process.env.UNTHREAD_SCALE_TIER_ID as string,
|
||||
// treat starter as free
|
||||
starter: process.env.UNTHREAD_FREE_TIER_ID as string,
|
||||
} as const;
|
||||
|
||||
function isValidPlan(plan: string): plan is keyof typeof planToCustomerId {
|
||||
return plan in planToCustomerId;
|
||||
}
|
||||
|
||||
function prepareEmailTitle(
|
||||
product: string,
|
||||
problemArea: string,
|
||||
email: string,
|
||||
name: string,
|
||||
) {
|
||||
const title =
|
||||
product && problemArea
|
||||
? `${product}: ${problemArea} (${email})`
|
||||
: `New ticket from ${name} (${email})`;
|
||||
return title;
|
||||
}
|
||||
|
||||
function prepareEmailBody(props: {
|
||||
product: string;
|
||||
markdownInput: string;
|
||||
email: string;
|
||||
name: string;
|
||||
extraInfoInput: Record<string, string>;
|
||||
walletAddress: string;
|
||||
telegramHandle: string;
|
||||
}) {
|
||||
const {
|
||||
extraInfoInput,
|
||||
email,
|
||||
walletAddress,
|
||||
product,
|
||||
name,
|
||||
markdownInput,
|
||||
telegramHandle,
|
||||
} = props;
|
||||
// Update `markdown` to include the infos from the form
|
||||
const extraInfo = Object.keys(extraInfoInput)
|
||||
.filter((key) => key.startsWith("extraInfo_"))
|
||||
.map((key) => {
|
||||
const prettifiedKey = `# ${key
|
||||
.replace("extraInfo_", "")
|
||||
.replaceAll("_", " ")}`;
|
||||
return `${prettifiedKey}: ${extraInfoInput[key] ?? "N/A"}\n`;
|
||||
})
|
||||
.join("");
|
||||
const markdown = `# Email: ${email}
|
||||
# Name: ${name}
|
||||
# Telegram: ${telegramHandle}
|
||||
# Wallet address: ${walletAddress}
|
||||
# Product: ${product}
|
||||
${extraInfo}
|
||||
# Message:
|
||||
${markdownInput}
|
||||
`;
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export async function createTicketAction(
|
||||
_previousState: State,
|
||||
formData: FormData,
|
||||
) {
|
||||
const teamId = formData.get("teamId")?.toString();
|
||||
|
||||
if (!teamId) {
|
||||
return {
|
||||
message: "teamId is required",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const team = await getTeamById(teamId);
|
||||
|
||||
if (!team) {
|
||||
return {
|
||||
message: `Team with id "${teamId}" not found`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [walletAddress, account] = await Promise.all([
|
||||
getAuthTokenWalletAddress(),
|
||||
getRawAccount(),
|
||||
]);
|
||||
|
||||
if (!walletAddress || !account) {
|
||||
loginRedirect("/support");
|
||||
}
|
||||
|
||||
const customerId = isValidPlan(team.supportPlan)
|
||||
? // fall back to "free" tier
|
||||
planToCustomerId[team.supportPlan] || planToCustomerId.free
|
||||
: // fallback to "free" tier
|
||||
planToCustomerId.free;
|
||||
|
||||
const product = formData.get("product")?.toString() || "";
|
||||
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
|
||||
const telegramHandle = formData.get("telegram")?.toString() || "";
|
||||
|
||||
const title = prepareEmailTitle(
|
||||
product,
|
||||
problemArea,
|
||||
account.email || "",
|
||||
account.name || "",
|
||||
);
|
||||
|
||||
const keyVal: Record<string, string> = {};
|
||||
for (const key of formData.keys()) {
|
||||
keyVal[key] = formData.get(key)?.toString() || "";
|
||||
}
|
||||
|
||||
const markdown = prepareEmailBody({
|
||||
email: account.email || "",
|
||||
extraInfoInput: keyVal,
|
||||
markdownInput: keyVal.markdown || "",
|
||||
name: account.name || "",
|
||||
product,
|
||||
telegramHandle: telegramHandle,
|
||||
walletAddress: walletAddress,
|
||||
});
|
||||
|
||||
const content = {
|
||||
customerId,
|
||||
emailInboxId: process.env.UNTHREAD_EMAIL_INBOX_ID,
|
||||
markdown,
|
||||
onBehalfOf: {
|
||||
email: account.email,
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
},
|
||||
status: "open",
|
||||
title,
|
||||
triageChannelId: process.env.UNTHREAD_TRIAGE_CHANNEL_ID,
|
||||
type: "email",
|
||||
};
|
||||
|
||||
// check files
|
||||
const files = formData.getAll("files") as File[];
|
||||
|
||||
if (files.length > 10) {
|
||||
return { message: "You can only attach 10 files at once.", success: false };
|
||||
}
|
||||
if (files.some((file) => file.size > 10 * 1024 * 1024)) {
|
||||
return { message: "The max file size is 20MB.", success: false };
|
||||
}
|
||||
|
||||
// add the content
|
||||
formData.append("json", JSON.stringify(content));
|
||||
|
||||
const KEEP_FIELDS = ["attachments", "json"];
|
||||
const keys = [...formData.keys()];
|
||||
// delete everything except attachments off of the form data
|
||||
for (const key of keys) {
|
||||
if (!KEEP_FIELDS.includes(key)) {
|
||||
formData.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// actually create the ticket
|
||||
const res = await fetch("https://api.unthread.io/api/conversations", {
|
||||
body: formData,
|
||||
headers: {
|
||||
"X-Api-Key": UNTHREAD_API_KEY,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to create ticket",
|
||||
res.status,
|
||||
res.statusText,
|
||||
await res.text(),
|
||||
);
|
||||
return {
|
||||
message: "Failed to create ticket, please try again later.",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Ticket created successfully",
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
-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 "../../../login/loginRedirect";
|
||||
import { CreateTicket } from "./components/create-ticket.client";
|
||||
|
||||
export default async function Page() {
|
||||
const teams = await getTeams();
|
||||
|
||||
const pagePath = "/support/create-ticket";
|
||||
|
||||
if (!teams || teams.length === 0) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb className="border-border border-b px-6 py-4">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/support">Support</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Get Support</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="container max-w-[750px] py-10">
|
||||
<CreateTicket
|
||||
teams={teams.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 849 KiB |
@@ -1,155 +0,0 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronRightIcon,
|
||||
HomeIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { CustomChatButton } from "@/components/chat/CustomChatButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EngineIcon } from "@/icons/EngineIcon";
|
||||
import { InsightIcon } from "@/icons/InsightIcon";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { siwaExamplePrompts } from "./definitions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
title: "thirdweb Support",
|
||||
},
|
||||
title: "thirdweb Support",
|
||||
};
|
||||
|
||||
const HELP_PRODUCTS = [
|
||||
{
|
||||
description: "Try out our interactive playground to get started",
|
||||
icon: HomeIcon,
|
||||
title: "Playground",
|
||||
viewAllUrl:
|
||||
"https://playground.thirdweb.com/connect/sign-in/button?tab=code",
|
||||
},
|
||||
{
|
||||
description: "Create and manage crypto wallets",
|
||||
icon: WalletIcon,
|
||||
title: "Wallets",
|
||||
viewAllUrl: "https://portal.thirdweb.com/connect",
|
||||
},
|
||||
{
|
||||
description: "Enable payments on any tokens on any chain",
|
||||
icon: PayIcon,
|
||||
title: "Payments",
|
||||
viewAllUrl: "https://portal.thirdweb.com/pay/troubleshoot",
|
||||
},
|
||||
{
|
||||
description: "Perform read and write transactions onchain",
|
||||
icon: EngineIcon,
|
||||
title: "Transactions",
|
||||
viewAllUrl: "https://portal.thirdweb.com/engine/v3/troubleshoot",
|
||||
},
|
||||
{
|
||||
description: "Query and analyze blockchain data",
|
||||
icon: InsightIcon,
|
||||
title: "Insight",
|
||||
viewAllUrl: "https://portal.thirdweb.com/insight",
|
||||
},
|
||||
{
|
||||
description: "API interface for LLMs",
|
||||
icon: NebulaIcon,
|
||||
title: "Nebula",
|
||||
viewAllUrl: "https://portal.thirdweb.com/nebula",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function SupportPage() {
|
||||
const [authToken, accountAddress] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getAuthTokenWalletAddress(),
|
||||
]);
|
||||
|
||||
const teams = await getTeams();
|
||||
const teamId = teams?.[0]?.id ?? undefined;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-12 pb-12">
|
||||
<div className="bg-gradient-to-b from-card/0 to-card py-20">
|
||||
<header className="container flex flex-col items-center gap-8">
|
||||
<div className="rounded-full bg-gradient-to-r from-[#F213A4] to-[#5204BF] p-2">
|
||||
<div className="rounded-full bg-background p-6 shadow-md">
|
||||
<BookOpenIcon className="size-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-2xl flex-col items-center gap-2">
|
||||
<h1 className="text-center font-bold text-4xl md:text-balance md:text-5xl">
|
||||
How can we help?
|
||||
</h1>
|
||||
<p className="text-center text-lg text-muted-foreground">
|
||||
Get instant answers with Nebula AI, our onchain support assistant.
|
||||
Still need help? You can also create a support case to reach our
|
||||
team.
|
||||
</p>
|
||||
<div className="mt-6 flex w-full flex-col items-center gap-3">
|
||||
<CustomChatButton
|
||||
authToken={authToken || undefined}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
isFloating={false}
|
||||
isLoggedIn={!!accountAddress}
|
||||
label="Ask AI for support"
|
||||
networks="all"
|
||||
pageType="support"
|
||||
teamId={teamId}
|
||||
/>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground text-sm hover:underline"
|
||||
href="/support/create-ticket"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open a support case
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<section className="container flex flex-col gap-6">
|
||||
<h2 className="font-bold text-3xl">Support Articles</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3">
|
||||
{HELP_PRODUCTS.map((product) => (
|
||||
<Card className="relative col-span-1 bg-card" key={product.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{product.icon && <product.icon className="size-5" />}
|
||||
<CardTitle className="text-xl">{product.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="absolute top-4 right-2 flex flex-row gap-1 px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link href={product.viewAllUrl} target="_blank">
|
||||
<span>View</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent key={product.description}>
|
||||
{product.description && (
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -63,14 +63,14 @@ export const HexConverter = () => {
|
||||
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<p>
|
||||
Or use the
|
||||
Convert values using the{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/typescript/v5"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
|
||||
+2
-2
@@ -49,14 +49,14 @@ export const Keccak256Converter = () => {
|
||||
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<p>
|
||||
Or use the{" "}
|
||||
Convert values using the{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/typescript/v5"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
|
||||
+2
-2
@@ -79,14 +79,14 @@ export const WeiConverter = () => {
|
||||
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<p>
|
||||
Or use the{" "}
|
||||
Convert values using the{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://portal.thirdweb.com/typescript/v5"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { createTeam } from "@/actions/createTeam";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import type { Project } from "@/api/projects";
|
||||
@@ -12,7 +13,6 @@ import { CustomConnectWallet } from "@/components/connect-wallet";
|
||||
import { LazyCreateProjectDialog } from "@/components/project/create-project-modal/LazyCreateAPIKeyDialog";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import {
|
||||
type AccountHeaderCompProps,
|
||||
AccountHeaderDesktopUI,
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
getProjectContracts,
|
||||
type ProjectContract,
|
||||
} from "../../../../../@/api/getProjectContracts";
|
||||
import { fetchChainWithLocalOverrides } from "../../../../../@/utils/fetchChainWithLocalOverrides";
|
||||
} from "@/api/getProjectContracts";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
|
||||
export async function getSortedDeployedContracts(params: {
|
||||
onlyMainnet?: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams, type Team } from "@/api/team";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
@@ -7,12 +8,8 @@ import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../@/api/auth-token";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { TWAutoConnect } from "../components/autoconnect";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
import { AccountHeader } from "./components/AccountHeader";
|
||||
import { getValidAccount } from "./settings/getAccount";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getMemberByAccountId } from "@/api/team-members";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
|
||||
import { getValidAccount } from "./settings/getAccount";
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { updateAccount } from "@/actions/updateAccount";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
export function AccountSettingsPage(props: {
|
||||
|
||||
@@ -285,7 +285,7 @@ function DeleteAccountCard(props: {
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
href="/support/create-ticket"
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { getAuthToken } from "../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { isAccountOnboardingComplete } from "../../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
/**
|
||||
* Just get the account object without enforcing onboarding.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getDefaultTeam } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AccountSettingsPage } from "./AccountSettingsPage";
|
||||
import { getValidAccount } from "./getAccount";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getLinkedWallets } from "@/api/linked-wallets";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { getValidAccount } from "../settings/getAccount";
|
||||
import { LinkWallet } from "./LinkWalletUI";
|
||||
|
||||
|
||||
@@ -44,15 +44,6 @@ export function SecondaryNavLinks() {
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
href="/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
href="https://feedback.thirdweb.com"
|
||||
|
||||
@@ -191,15 +191,6 @@ export function MobileBurgerMenuButton(
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground "
|
||||
href="/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href="https://feedback.thirdweb.com"
|
||||
|
||||
@@ -4,7 +4,10 @@ function prefixId(id: string, prefix?: string) {
|
||||
return prefix ? `${prefix}_${id}` : id;
|
||||
}
|
||||
|
||||
export function ThirdwebMiniLogo(props: { className?: string }) {
|
||||
export function ThirdwebMiniLogo(props: {
|
||||
className?: string;
|
||||
isMonoChrome?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<svg className={props.className} fill="none" viewBox="0 0 516 321">
|
||||
@@ -12,15 +15,27 @@ export function ThirdwebMiniLogo(props: { className?: string }) {
|
||||
<g clipPath={`url(#${prefixId("clip0_3:35)", id)}`}>
|
||||
<path
|
||||
d="M1.40497 27.0011C-3.73633 14.022 5.84519 0 19.8669 0H106.919C115.098 0 122.342 4.86715 125.381 12.3996L194.671 185.299C196.541 189.935 196.541 195.149 194.671 199.901L151.087 308.484C144.427 325.056 120.823 325.056 114.163 308.484L1.40497 27.0011Z"
|
||||
fill={`url(#${prefixId("paint0_linear_3:35)", id)}`}
|
||||
fill={
|
||||
props.isMonoChrome
|
||||
? "currentColor"
|
||||
: `url(#${prefixId("paint0_linear_3:35)", id)}`
|
||||
}
|
||||
/>
|
||||
<path
|
||||
d="M169.547 26.4217C164.873 13.5585 174.454 0 188.242 0H264.077C272.49 0 279.968 5.2148 282.772 12.9791L345.753 185.879C347.272 190.166 347.272 194.918 345.753 199.321L307.894 303.27C301.585 320.652 276.813 320.652 270.503 303.27L169.547 26.4217Z"
|
||||
fill={`url(#${prefixId("paint1_linear_3:35)", id)}`}
|
||||
fill={
|
||||
props.isMonoChrome
|
||||
? "currentColor"
|
||||
: `url(#${prefixId("paint1_linear_3:35)", id)}`
|
||||
}
|
||||
/>
|
||||
<path
|
||||
d="M321.331 27.0011C316.19 14.022 325.771 0 339.793 0H426.845C435.024 0 442.269 4.86715 445.307 12.3996L514.597 185.299C516.467 189.935 516.467 195.149 514.597 199.901L471.013 308.484C464.353 325.056 440.75 325.056 434.089 308.484L321.331 27.0011Z"
|
||||
fill={`url(#${prefixId("paint2_linear_3:35)", id)}`}
|
||||
fill={
|
||||
props.isMonoChrome
|
||||
? "currentColor"
|
||||
: `url(#${prefixId("paint2_linear_3:35)", id)}`
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../../../@/api/auth-token";
|
||||
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
|
||||
import { InviteTeamMembers } from "../../../../login/onboarding/team-onboarding/team-onboarding";
|
||||
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
|
||||
import { InviteTeamMembers } from "../../../../../login/onboarding/team-onboarding/team-onboarding";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamBySlug, getTeams } from "@/api/team";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import {
|
||||
getAuthToken,
|
||||
getAuthTokenWalletAddress,
|
||||
} from "../../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { getValidAccount } from "../../../account/settings/getAccount";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { TeamHeaderLoggedIn } from "../../../team/components/TeamHeader/team-header-logged-in.client";
|
||||
|
||||
export default async function Layout(props: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { TeamOnboardingLayout } from "../../../login/onboarding/onboarding-layout";
|
||||
import { TeamInfoForm } from "../../../login/onboarding/team-onboarding/team-onboarding";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
|
||||
import { TeamInfoForm } from "../../../../login/onboarding/team-onboarding/team-onboarding";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug, type Team } from "@/api/team";
|
||||
import { getAuthToken } from "../../../../../../@/api/auth-token";
|
||||
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
|
||||
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
|
||||
import { PlanSelector } from "./_components/plan-selector";
|
||||
|
||||
export default async function Page(props: {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckIcon, UsersIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { acceptInvite } from "@/actions/acceptInvite";
|
||||
import type { Team } from "@/api/team";
|
||||
@@ -65,19 +64,7 @@ function Header() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
className="px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
href="/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
<ToggleThemeButton />
|
||||
</div>
|
||||
<ToggleThemeButton />
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={cn("bg-background font-sans antialiased", fontSans.variable)}
|
||||
>
|
||||
<AppRouterProviders>{children}</AppRouterProviders>
|
||||
<AppRouterProviders autoConnect={true}>{children}</AppRouterProviders>
|
||||
<DashboardRouterTopProgressBar />
|
||||
<NextTopLoader
|
||||
color="hsl(var(--foreground))"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import {
|
||||
ThirdwebProvider,
|
||||
useActiveAccount,
|
||||
useConnectionManager,
|
||||
} from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "@/components/connect-wallet";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { isSanctionedAddress } from "@/constants/eth-sanctioned-addresses";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
@@ -20,21 +20,24 @@ import { TWAutoConnect } from "./components/autoconnect";
|
||||
const queryClient = new QueryClient();
|
||||
const thirdwebClient = getClientThirdwebClient();
|
||||
|
||||
export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
export function AppRouterProviders(props: {
|
||||
children: React.ReactNode;
|
||||
autoConnect: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SyncChainStores />
|
||||
<ThirdwebProvider>
|
||||
<SyncChainDefinitionsToConnectionManager />
|
||||
<TWAutoConnect client={thirdwebClient} />
|
||||
{props.autoConnect && <TWAutoConnect client={thirdwebClient} />}
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
>
|
||||
<ToasterSetup />
|
||||
<Toaster richColors />
|
||||
<SanctionedAddressesChecker>
|
||||
{props.children}
|
||||
</SanctionedAddressesChecker>
|
||||
@@ -45,11 +48,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToasterSetup() {
|
||||
const { theme } = useTheme();
|
||||
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
|
||||
}
|
||||
|
||||
function SyncChainDefinitionsToConnectionManager() {
|
||||
const { allChainsV5 } = useAllChainsData();
|
||||
const connectionManager = useConnectionManager();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DatabaseIcon,
|
||||
DollarSignIcon,
|
||||
FileTextIcon,
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
WalletCardsIcon,
|
||||
@@ -89,6 +90,11 @@ export function TeamSidebarLayout(props: {
|
||||
: []),
|
||||
]}
|
||||
footerSidebarLinks={[
|
||||
{
|
||||
href: `${layoutPath}/~/support`,
|
||||
icon: HelpCircleIcon,
|
||||
label: "Support",
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/~/billing`,
|
||||
icon: DollarSignIcon,
|
||||
|
||||
@@ -7,9 +7,9 @@ import { getChainSubscriptions } from "@/api/team-subscription";
|
||||
import { CustomChatButton } from "@/components/chat/CustomChatButton";
|
||||
import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { siwaExamplePrompts } from "@/constants/siwa-example-prompts";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getChain } from "../../../(dashboard)/(chain)/utils";
|
||||
import { siwaExamplePrompts } from "../../../(dashboard)/support/definitions";
|
||||
import { getValidAccount } from "../../../account/settings/getAccount";
|
||||
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
|
||||
import { StaffModeNotice } from "./_components/StaffModeNotice";
|
||||
@@ -103,17 +103,13 @@ export default async function TeamLayout(props: {
|
||||
>
|
||||
{props.children}
|
||||
</TeamSidebarLayout>
|
||||
<div className="fixed right-6 bottom-6 z-50">
|
||||
<div className="fixed right-4 bottom-4 z-50">
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
isFloating={true}
|
||||
isLoggedIn={true}
|
||||
label="Ask AI Assistant"
|
||||
networks="all"
|
||||
pageType="support"
|
||||
teamId={team.id}
|
||||
label="Get Help"
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { subDays } from "date-fns";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getWalletConnections } from "@/api/analytics";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProjects, type Project } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { Changelog } from "./_components/Changelog";
|
||||
import { FreePlanUpsellBannerUI } from "./_components/FreePlanUpsellBannerUI";
|
||||
import { InviteTeamMembersButton } from "./_components/invite-team-members-button";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { getAuthToken } from "@/api/auth-token";
|
||||
import { fetchEcosystem } from "@/api/ecosystems";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AddPartnerForm } from "../components/client/add-partner-form.client";
|
||||
|
||||
export default async function AddPartnerPage({
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { getAuthToken } from "@/api/auth-token";
|
||||
import { fetchEcosystem } from "@/api/ecosystems";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { UpdatePartnerForm } from "../../../components/client/update-partner-form.client";
|
||||
import { fetchPartnerDetails } from "../../../hooks/fetchPartnerDetails";
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getAuthToken } from "../../../../../../../../../@/api/auth-token";
|
||||
import { loginRedirect } from "../../../../../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { EcosystemPermissionsPage } from "./configuration/components/client/EcosystemPermissionsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
import { loginRedirect } from "@app/login/loginRedirect";
|
||||
import { InAppWalletUsersPageContent } from "@app/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { fetchEcosystem } from "@/api/ecosystems";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
export default async function EcosystemUsersPage(props: {
|
||||
params: Promise<{ team_slug: string; slug: string }>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { fetchEcosystemList } from "@/api/ecosystems";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
|
||||
+1
-1
@@ -5,9 +5,9 @@ import { formatToDollars } from "@/components/billing/formatToDollars";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { ToolTipLabel } from "../../../../../../../../@/components/ui/tooltip";
|
||||
import { getChain } from "../../../../../../(dashboard)/(chain)/utils";
|
||||
import { InfraServiceCard } from "./_components/service-card";
|
||||
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import type { Team } from "@/api/team";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "../../../../../../../../@/api/auth-token";
|
||||
|
||||
export async function updateTeam(params: {
|
||||
teamId: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import type { TeamAccountRole } from "@/api/team-members";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { teamStub } from "@/storybook/stubs";
|
||||
import { storybookThirdwebClient } from "../../../../../../../../@/storybook/utils";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { InviteSection } from "./InviteSection";
|
||||
|
||||
const meta = {
|
||||
|
||||
+1
-1
@@ -37,8 +37,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { getValidTeamPlan } from "../../../../../../../../@/utils/getValidTeamPlan";
|
||||
|
||||
const inviteFormSchema = z.object({
|
||||
invites: z
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getTeamInvites } from "@/api/team-invites";
|
||||
import { getMembers } from "@/api/team-members";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../../../../../@/api/auth-token";
|
||||
import { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
import { getRecommendedMembers } from "./getRecommendedMembers";
|
||||
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getMemberByAccountId } from "@/api/team-members";
|
||||
import { checkDomainVerification } from "@/api/verified-domain";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getAuthToken } from "../../../../../../../@/api/auth-token";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { TeamGeneralSettingsPage } from "./general/TeamGeneralSettingsPage";
|
||||
|
||||
|
||||
+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>
|
||||
);
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user