diff --git a/apps/dashboard/redirects.js b/apps/dashboard/redirects.js index e0a4251e5ff..6acffb4a2f9 100644 --- a/apps/dashboard/redirects.js +++ b/apps/dashboard/redirects.js @@ -448,6 +448,11 @@ async function redirects() { ...legacyDashboardToTeamRedirects, ...projectPageRedirects, ...teamPageRedirects, + { + source: "/support/:path*", + destination: "/team/~/~/support", + permanent: false, + }, ]; } diff --git a/apps/dashboard/src/@/api/support.ts b/apps/dashboard/src/@/api/support.ts new file mode 100644 index 00000000000..2badc84828f --- /dev/null +++ b/apps/dashboard/src/@/api/support.ts @@ -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 = { + 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 = { + 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"}`, + }; + } +} diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts index f8eeb3f43da..86ebb432d09 100644 --- a/apps/dashboard/src/@/api/team.ts +++ b/apps/dashboard/src/@/api/team.ts @@ -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); } diff --git a/apps/dashboard/src/@/components/chat/ChatBar.tsx b/apps/dashboard/src/@/components/chat/ChatBar.tsx index 9339a0ef70d..6f3794e56c6 100644 --- a/apps/dashboard/src/@/components/chat/ChatBar.tsx +++ b/apps/dashboard/src/@/components/chat/ChatBar.tsx @@ -116,13 +116,12 @@ export function ChatBar(props: { ) : ( diff --git a/apps/dashboard/src/@/components/chat/CustomChatButton.tsx b/apps/dashboard/src/@/components/chat/CustomChatButton.tsx index c6ebb0bf673..7fd34e70f5f 100644 --- a/apps/dashboard/src/@/components/chat/CustomChatButton.tsx +++ b/apps/dashboard/src/@/components/chat/CustomChatButton.tsx @@ -3,6 +3,7 @@ import { MessageCircleIcon, XIcon } from "lucide-react"; 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,16 +15,11 @@ 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 [isOpen, setIsOpen] = useState(false); const [hasBeenOpened, setHasBeenOpened] = useState(false); @@ -54,14 +50,14 @@ export function CustomChatButton(props: { ref={ref} > {/* Header with close button */} -
+
{props.label}
diff --git a/apps/dashboard/src/@/components/chat/CustomChatContent.tsx b/apps/dashboard/src/@/components/chat/CustomChatContent.tsx index 6d46e6e0092..140d03345b0 100644 --- a/apps/dashboard/src/@/components/chat/CustomChatContent.tsx +++ b/apps/dashboard/src/@/components/chat/CustomChatContent.tsx @@ -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 ; - } - return ( ); } 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 (
@@ -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} /> )} -
-
-
- -
-
-
- -

- How can I help you
- today? -

- -
-

- Sign in to use AI Assistant -

-
- - -
- ); -} - function EmptyStateChatPageContent(props: { sendMessage: (message: UserMessage) => void; examplePrompts: { title: string; message: string }[]; @@ -288,9 +264,12 @@ function EmptyStateChatPageContent(props: { return (
-
-
- +
+
+
@@ -301,11 +280,12 @@ function EmptyStateChatPageContent(props: {
-
+
{props.examplePrompts.map((prompt) => ( + + + + + + + Create Support Case + +

+ Let's create a detailed support case for + our technical team. +

+
+ { + 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.`, + ); + } + }} + /> +
+
+ +
+ )} + + )}
); })} @@ -143,7 +218,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; }) { const { message } = props; @@ -179,13 +254,16 @@ function RenderMessage(props: {
{(message.type === "presence" || message.type === "assistant") && ( - + )} {message.type === "error" && ( @@ -227,7 +305,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; className?: string; }) { const [isSubmitting, setIsSubmitting] = useState(false); @@ -240,9 +318,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 +329,26 @@ function CustomFeedbackButtons(props: { } return ( -
- - + + +
); } @@ -296,7 +375,11 @@ function RenderResponse(props: { ); case "presence": - return ; + return ( +
+ +
+ ); case "error": return ( @@ -327,7 +410,7 @@ function StyledMarkdownRenderer(props: { - - - {isOpen && ( - <> - {showAll && props.texts.length > 0 && ( -
    - {props.texts.map((text) => ( -
  • - {text.trim()} -
  • - ))} -
- )} - - {!showAll && lastText && ( -
- {lastText.trim()} -
- )} - - )} - - ); -} diff --git a/apps/dashboard/src/@/components/chat/types.ts b/apps/dashboard/src/@/components/chat/types.ts index ba1aaaa0d2b..b640dc94840 100644 --- a/apps/dashboard/src/@/components/chat/types.ts +++ b/apps/dashboard/src/@/components/chat/types.ts @@ -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"; diff --git a/apps/dashboard/src/@/components/ui/button.tsx b/apps/dashboard/src/@/components/ui/button.tsx index 5e36eb49920..42e7d3e92b8 100644 --- a/apps/dashboard/src/@/components/ui/button.tsx +++ b/apps/dashboard/src/@/components/ui/button.tsx @@ -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 ", diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/definitions.ts b/apps/dashboard/src/@/constants/siwa-example-prompts.ts similarity index 100% rename from apps/dashboard/src/app/(app)/(dashboard)/support/definitions.ts rename to apps/dashboard/src/@/constants/siwa-example-prompts.ts diff --git a/apps/dashboard/src/@/contexts/error-handler.tsx b/apps/dashboard/src/@/contexts/error-handler.tsx index 2e4f5ff9788..71015e3307f 100644 --- a/apps/dashboard/src/@/contexts/error-handler.tsx +++ b/apps/dashboard/src/@/contexts/error-handler.tsx @@ -150,9 +150,13 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => { /> )} -
diff --git a/apps/dashboard/src/@/utils/errorParser.tsx b/apps/dashboard/src/@/utils/errorParser.tsx index 44c6e433462..85dbfa5b31b 100644 --- a/apps/dashboard/src/@/utils/errorParser.tsx +++ b/apps/dashboard/src/@/utils/errorParser.tsx @@ -4,14 +4,14 @@ import type { JSX } from "react"; const PLEASE_REACH_OUT_MESSAGE = ( - If you believe this is incorrect or the error persists, please visit our{" "} + If you believe this is incorrect or the error persists, please{" "} - support site + contact support . diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/account/index.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/account/index.tsx deleted file mode 100644 index 28d52421d3e..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/account/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { type ReactElement, useState } from "react"; -import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader"; -import { DescriptionInput } from "../shared/SupportForm_DescriptionInput"; -import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput"; - -type ProblemAreaItem = { - label: string; - component: ReactElement; -}; - -const ACCOUNT_PROBLEM_AREAS: ProblemAreaItem[] = [ - { - component: ( - <> - - - - ), - label: "Pricing inquiry", - }, - { - component: ( - <> - - - - ), - label: "Billing inquiry", - }, - { - component: ( - <> - - - - ), - label: "Usage inquiry", - }, - { - component: ( - <> - - - - ), - label: "Other", - }, -]; - -export default function AccountSupportForm() { - const [problemArea, setProblemArea] = useState(""); - - return ( - <> - o.label)} - promptText="Select a problem area" - required={true} - value={problemArea} - /> - {ACCOUNT_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component} - - ); -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/other/index.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/other/index.tsx deleted file mode 100644 index fcbc0440670..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/other/index.tsx +++ /dev/null @@ -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: ( - <> - - - - ), - label: "General inquiry", - }, - { - component: ( - <> - - - - ), - label: "Security", - }, - { - component: ( - <> - - - - ), - label: "Feedback", - }, - { - component: ( - <> - - - - ), - label: "Other", - }, -]; - -export default function OtherSupportForm() { - const [problemArea, setProblemArea] = useState(""); - return ( - <> - o.label)} - promptText="Select a problem area" - required={true} - value={problemArea} - /> - {OTHER_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component} - - ); -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_AttachmentUploader.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_AttachmentUploader.tsx deleted file mode 100644 index 9cd3479f633..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_AttachmentUploader.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Label } from "@/components/ui/label"; - -export const AttachmentForm = () => { - return ( -
- - -
- ); -}; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx deleted file mode 100644 index 32db19a1465..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection.tsx +++ /dev/null @@ -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 ( -
- - - -
- ); -}; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TelegramInput.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TelegramInput.tsx deleted file mode 100644 index fc79ad23bd3..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TelegramInput.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Label } from "@/components/ui/label"; - -type Props = { - placeholder?: string; -}; - -const defaultDescription = "@YourHandle"; - -export const SupportForm_TelegramInput = (props: Props) => { - return ( -
- - - -
- ); -}; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/create-ticket.action.ts b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/create-ticket.action.ts deleted file mode 100644 index 60c6b7b17e9..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/create-ticket.action.ts +++ /dev/null @@ -1,208 +0,0 @@ -"use server"; -import "server-only"; - -import { getAuthTokenWalletAddress } from "@/api/auth-token"; -import { getTeamById } from "@/api/team"; -import { loginRedirect } from "@/utils/redirects"; -import { getRawAccount } from "../../../../account/settings/getAccount"; - -type State = { - success: boolean; - message: string; -}; - -const UNTHREAD_API_KEY = process.env.UNTHREAD_API_KEY || ""; - -const planToCustomerId = { - accelerate: process.env.UNTHREAD_ACCELERATE_TIER_ID as string, - free: process.env.UNTHREAD_FREE_TIER_ID as string, - growth: process.env.UNTHREAD_GROWTH_TIER_ID as string, - pro: process.env.UNTHREAD_PRO_TIER_ID as string, - scale: process.env.UNTHREAD_SCALE_TIER_ID as string, - // treat starter as free - starter: process.env.UNTHREAD_FREE_TIER_ID as string, -} as const; - -function isValidPlan(plan: string): plan is keyof typeof planToCustomerId { - return plan in planToCustomerId; -} - -function prepareEmailTitle( - product: string, - problemArea: string, - email: string, - name: string, -) { - const title = - product && problemArea - ? `${product}: ${problemArea} (${email})` - : `New ticket from ${name} (${email})`; - return title; -} - -function prepareEmailBody(props: { - product: string; - markdownInput: string; - email: string; - name: string; - extraInfoInput: Record; - 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 = {}; - 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, - }; -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/create-ticket.client.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/create-ticket.client.tsx deleted file mode 100644 index 6b34630770f..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/create-ticket.client.tsx +++ /dev/null @@ -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: () => , - ssr: false, -}); -const EngineSupportForm = dynamic(() => import("./contact-forms/engine"), { - loading: () => , - ssr: false, -}); -const ContractSupportForm = dynamic(() => import("./contact-forms/contracts"), { - loading: () => , - ssr: false, -}); -const AccountSupportForm = dynamic(() => import("./contact-forms/account"), { - loading: () => , - ssr: false, -}); -const OtherSupportForm = dynamic(() => import("./contact-forms/other"), { - loading: () => , - ssr: false, -}); - -const productOptions: { label: string; component: ReactElement }[] = [ - { - component: , - label: "Connect", - }, - { - component: , - label: "Engine", - }, - { - component: , - label: "Contracts", - }, - { - component: , - label: "Account", - }, - { - component: , - label: "Other", - }, -]; - -function ProductAreaSelection(props: { - productLabel: string; - setProductLabel: (val: string) => void; -}) { - const { productLabel, setProductLabel } = props; - - return ( -
- o.label)} - promptText="Select a product" - required={true} - value={productLabel} - /> - {productOptions.find((o) => o.label === productLabel)?.component} -
- ); -} - -export function CreateTicket(props: { - teams: { - name: string; - id: string; - }[]; -}) { - const formRef = useRef(null); - const [selectedTeamId, setSelectedTeamId] = useState( - 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 ( -
-
-

Get Support

-

- We are here to help. Ask product questions, report problems, or leave - feedback. -

- -
- -
- {/* Don't conditionally render this - it has be rendered to submit the input values */} -
- setSelectedTeamId(teamId)} - selectedTeamId={selectedTeamId} - teams={props.teams} - /> -
- - - - -
-
- -
- -
- - -
- - ); -} - -function SubmitButton() { - const { pending } = useFormStatus(); - return ( - - ); -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/page.tsx deleted file mode 100644 index a4e9cc9751a..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import Link from "next/link"; -import { getTeams } from "@/api/team"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import { loginRedirect } from "@/utils/redirects"; -import { CreateTicket } from "./components/create-ticket.client"; - -export default async function Page() { - const teams = await getTeams(); - - const pagePath = "/support/create-ticket"; - - if (!teams || teams.length === 0) { - loginRedirect(pagePath); - } - - return ( -
- - - - - Support - - - - - Get Support - - - -
- ({ - id: t.id, - name: t.name, - }))} - /> -
-
- ); -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/layout.tsx deleted file mode 100644 index 6d66f7ddf08..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { TeamHeader } from "../../team/components/TeamHeader/team-header"; - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( -
-
- -
- {children} -
- ); -} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/opengraph-image.png b/apps/dashboard/src/app/(app)/(dashboard)/support/opengraph-image.png deleted file mode 100644 index b41aca75a60..00000000000 Binary files a/apps/dashboard/src/app/(app)/(dashboard)/support/opengraph-image.png and /dev/null differ diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx deleted file mode 100644 index bce10cb8765..00000000000 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx +++ /dev/null @@ -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 ( -
-
-
-
-
- -
-
-
-

- How can we help? -

-

- Get instant answers with Nebula AI, our onchain support assistant. - Still need help? You can also create a support case to reach our - team. -

-
- - - - Open a support case - -
-
-
-
-
-

Support Articles

-
- {HELP_PRODUCTS.map((product) => ( - - -
- {product.icon && } - {product.title} -
- -
- - - {product.description && ( -

- {product.description} -

- )} -
-
- ))} -
-
-
- ); -} diff --git a/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx b/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx index 395dcd53a63..b963965bb0c 100644 --- a/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx +++ b/apps/dashboard/src/app/(app)/account/settings/AccountSettingsPageUI.tsx @@ -285,7 +285,7 @@ function DeleteAccountCard(props: { variant="outline" > diff --git a/apps/dashboard/src/app/(app)/components/Header/SecondaryNav/SecondaryNav.tsx b/apps/dashboard/src/app/(app)/components/Header/SecondaryNav/SecondaryNav.tsx index 3f505aa1602..bb74c1224f2 100644 --- a/apps/dashboard/src/app/(app)/components/Header/SecondaryNav/SecondaryNav.tsx +++ b/apps/dashboard/src/app/(app)/components/Header/SecondaryNav/SecondaryNav.tsx @@ -44,15 +44,6 @@ export function SecondaryNavLinks() { Docs - - Support - - - - Support - - @@ -12,15 +15,27 @@ export function ThirdwebMiniLogo(props: { className?: string }) { diff --git a/apps/dashboard/src/app/(app)/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx b/apps/dashboard/src/app/(app)/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx index 3d042f9b84c..9c375a20354 100644 --- a/apps/dashboard/src/app/(app)/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx +++ b/apps/dashboard/src/app/(app)/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx @@ -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() {
-
-
- - Support - -
- -
+
); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx index d28df97442b..e83543e8844 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/TeamSidebarLayout.tsx @@ -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, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx index 02cf04df252..a25b9229d46 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/layout.tsx @@ -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} -
+
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/CreateSupportCase.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/CreateSupportCase.tsx new file mode 100644 index 00000000000..00664f97b80 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/CreateSupportCase.tsx @@ -0,0 +1,322 @@ +"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, + DialogTrigger, +} 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( + 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 = ( +
+ +
+ ); + + const userIcon = ( +
+ +
+ ); + + return ( +
+
+

+ Chat with support +

+ +

+ Describe your issue and we'll help you resolve it +

+
+ {/* Chat Messages */} + +
+ {chatMessages.map((message, index) => ( +
+ {message.isUser ? ( +
+ {userIcon} +
+ +
+
+ ) : message.content === "__reasoning__" ? ( +
+ {aiIcon} + +
+ ) : ( +
+ {aiIcon} +
+ + + {/* 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 && ( +
+ + + + + + + + + Create Support Case + +

+ Let's create a detailed support case for our + technical team. +

+
+ + { + 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, + }, + ]); + }} + /> +
+
+
+
+ )} + + {/* Show Back to Support button for success message */} + {!message.isUser && message.isSuccessMessage && ( +
+ +
+ )} +
+
+ )} +
+ ))} +
+
+ + {/* Chat Input */} + {!showCreateForm && ( +
+
+ setChatInput(e.target.value)} + onKeyDown={handleChatKeyPress} + className="min-h-[120px] rounded-xl" + /> + +
+
+ )} +
+ ); +} + +function StyledMarkdownRenderer(props: { + text: string; + isMessagePending: boolean; + type: "assistant" | "user"; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx new file mode 100644 index 00000000000..00ad4b4fb79 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx @@ -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 ( +
+
+ + + + + Cases + + + + + + +
+ + + +
+ +
+
+ {localMessages && localMessages.length > 0 ? ( + localMessages.map((message) => { + return ; + }) + ) : ( +
+
+ No Messages Found +
+
+ )} +
+ + {ticket.status === "closed" && ( +
+

+ This ticket is closed. If you need further assistance, please + create a new ticket. +

+
+ )} +
+ +
+ + {/* Reply Section */} + {ticket.status !== "closed" && ticket.status !== "resolved" && ( +
+
+ setReplyMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Your message..." + value={replyMessage} + className="min-h-[120px] bg-card leading-relaxed rounded-lg" + /> + +
+
+ )} +
+ ); +} + +function TicketHeader(props: { + title: string; + createdAt: string; + updatedAt: string; + status: SupportTicket["status"]; +}) { + return ( +
+
+

+ {props.title} +

+ +
+

+ Opened on {format(new Date(props.createdAt), "MMM d, yyyy")} +

+ +
+ +

+ Last updated on {format(new Date(props.updatedAt), "MMM d, yyyy")} +

+
+
+ + + {getTicketStatusLabel(props.status)} + +
+ ); +} + +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 ( +
+ {/* left - icon */} +
+ {isCustomer ? ( + + ) : ( + + )} +
+ {/* right */} +
+ {/* User, Timestamp */} +
+
+ + {displayName} + + + {format(new Date(message.timestamp), "MMM d, yyyy 'at' h:mm a")} + +
+ + +
+ + {/* message */} + +
+ {isExpanded ? ( +
+ {messageContent} +
+ ) : ( +
+ )} +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportHeader.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportHeader.tsx new file mode 100644 index 00000000000..ca127667255 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportHeader.tsx @@ -0,0 +1,40 @@ +"use client"; +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +export function SupportHeader(props: { teamSlug: string }) { + const layoutSegment = useSelectedLayoutSegment(); + + return ( +
+
+
+

+ Support +

+

+ Create and view support cases for your projects +

+
+ {layoutSegment === null ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm.tsx new file mode 100644 index 00000000000..fe6cd07caa5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm.tsx @@ -0,0 +1,220 @@ +import dynamic from "next/dynamic"; +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: () => , + ssr: false, + }, +); +const EngineSupportForm = dynamic( + () => import("./contact-forms/engine").then((mod) => mod.EngineSupportForm), + { + loading: () => , + ssr: false, + }, +); +const ContractSupportForm = dynamic( + () => + import("./contact-forms/contracts").then((mod) => mod.ContractSupportForm), + { + loading: () => , + ssr: false, + }, +); +const AccountSupportForm = dynamic( + () => import("./contact-forms/account").then((mod) => mod.AccountSupportForm), + { + loading: () => , + ssr: false, + }, +); +const OtherSupportForm = dynamic( + () => import("./contact-forms/other").then((mod) => mod.OtherSupportForm), + { + loading: () => , + ssr: false, + }, +); +const PaymentsSupportForm = dynamic( + () => + import("./contact-forms/payments").then((mod) => mod.PaymentsSupportForm), + { + loading: () => , + ssr: false, + }, +); +const TokensMarketplaceSupportForm = dynamic( + () => + import("./contact-forms/tokens-marketplace").then( + (mod) => mod.TokensMarketplaceSupportForm, + ), + { + loading: () => , + ssr: false, + }, +); + +const productOptions = [ + { component: , label: "Wallets" }, + { component: , label: "Transactions" }, + { component: , label: "Payments" }, + { component: , label: "Contracts" }, + { + component: , + label: "Tokens / Marketplace", + }, + { component: , label: "Account" }, + { component: , label: "Other" }, +]; + +function ProductAreaSelection(props: { + productLabel: string; + setProductLabel: (val: string) => void; +}) { + const { productLabel, setProductLabel } = props; + return ( +
+ o.label)} + promptText="Brief description of your issue" + required={true} + value={productLabel} + /> + {productOptions.find((o) => o.label === productLabel)?.component} +
+ ); +} + +interface SupportTicketFormProps { + team: Team; + productLabel: string; + setProductLabel: (val: string) => void; + onSuccess?: () => void; + conversationId?: string; +} + +export function SupportTicketForm({ + team, + productLabel, + setProductLabel, + onSuccess, + conversationId, +}: SupportTicketFormProps) { + const [isSubmittingForm, setIsSubmittingForm] = useState(false); + const formRef = useRef(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); + } + }; + + return ( +
+
+ +
+ {/* Submit Buttons */} +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/case-filters.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/case-filters.tsx new file mode 100644 index 00000000000..3c011a149e2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/case-filters.tsx @@ -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 ( +
+ {/* Search Bar */} +
+ + onSearchChange(e.target.value)} + placeholder="Search" + value={searchQuery} + /> +
+ + {/* Tab Buttons */} +
+ {tabs.map((tab) => ( + + ))} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/case-list.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/case-list.tsx new file mode 100644 index 00000000000..63a3736adbc --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/case-list.tsx @@ -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 ( +
+ + +
+ + {filteredTickets.length === 0 ? ( +
+
+
+
+ +
+
+ +

+ {activeTab === "all" + ? "No Support Cases" + : `No ${activeTab} cases found`} +

+
+
+ ) : ( + + + + + Title + Status + Last Updated + + + + {filteredTickets.map((ticket) => ( + + + + {ticket.title} + + + + + {getTicketStatusLabel(ticket.status)} + + + + +
+ {formatDistanceToNow(new Date(ticket.updatedAt), { + addSuffix: true, + })} +
+
+
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/account/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/account/index.tsx new file mode 100644 index 00000000000..f88f8dc479f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/account/index.tsx @@ -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(""); + const [description, setDescription] = useState(""); + + return ( + <> + + {problemArea && ( + + )} + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/connect/AffectedAreaInput.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/AffectedAreaInput.tsx similarity index 71% rename from apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/connect/AffectedAreaInput.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/AffectedAreaInput.tsx index a7b2485451e..9f0d8170098 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/connect/AffectedAreaInput.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/AffectedAreaInput.tsx @@ -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(""); const [selectedSDK, setSelectedSDK] = useState(""); + const [description, setDescription] = useState(""); + const [sdkDescription, setSdkDescription] = useState(""); + return ( <> { inputType="url" required={false} /> - - + )} ) : ( - <> - - - + ))} ); diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/connect/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/index.tsx similarity index 76% rename from apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/connect/index.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/index.tsx index a47f4f6a6ad..5e61bb26d14 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/connect/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/index.tsx @@ -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(""); + return ; +}; + const PROBLEM_AREAS: ProblemAreaItem[] = [ { component: , - label: "Embedded wallet login issues", + label: "In-app wallet login issues", }, { component: , - label: "Embedded wallet transaction issues", + label: "In-app wallet transaction issues", }, { component: , - label: "Embedded wallet Custom Auth", + label: "In-app wallet Custom Auth", }, { component: , label: "Account Abstraction", }, - { - component: , - label: "In-app wallet", - }, { component: ( <> @@ -66,8 +66,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [ inputType="url" required={false} /> - - + ), label: "Connect SDKs", @@ -77,8 +76,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [ <> - - + ), label: "Unity SDK", @@ -101,8 +99,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [ inputType="text" required={false} /> - - + ), label: ".NET SDK", @@ -117,7 +114,7 @@ const PROBLEM_AREAS: ProblemAreaItem[] = [ }, ]; -export default function ConnectSupportForm() { +export function ConnectSupportForm() { const [selectedProblemArea, setSelectedProblemArea] = useState(""); return ( diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/contracts/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/contracts/index.tsx similarity index 80% rename from apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/contracts/index.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/contracts/index.tsx index c06eeea8077..d9413073ed0 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/contracts/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/contracts/index.tsx @@ -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(""); + return ; +}; + const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [ { component: ( @@ -69,8 +73,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [ - - + ), label: "Deploying a contract", @@ -81,8 +84,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [ - - + ), label: "Contract verification", @@ -94,8 +96,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [ - - + ), label: "Calling a function in my contract", @@ -103,8 +104,7 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [ { component: ( <> - - + ), label: "Developing a custom contract", @@ -112,15 +112,14 @@ const CONTRACT_PROBLEM_AREAS: ProblemAreaItem[] = [ { component: ( <> - - + ), label: "Other", }, ]; -export default function ContractSupportForm() { +export function ContractSupportForm() { const [problemArea, setProblemArea] = useState(""); return ( <> diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/engine/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/engine/index.tsx similarity index 72% rename from apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/engine/index.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/engine/index.tsx index 860765bccfc..4ba340c4614 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/engine/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/engine/index.tsx @@ -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(""); const [problemArea, setProblemArea] = useState(""); + const [description, setDescription] = useState(""); + return ( <> + />{" "} {selectedEngineType && ( <> - - + )} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/other/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/other/index.tsx new file mode 100644 index 00000000000..075b94f55a1 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/other/index.tsx @@ -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(""); + + return ; +}; + +const OTHER_PROBLEM_AREAS = [ + "General inquiry", + "Feature request", + "Bug report", + "Documentation", + "Integration help", + "Other", +]; +export function OtherSupportForm() { + const [problemArea, setProblemArea] = useState(""); + + return ( + <> + + {problemArea && } + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/payments/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/payments/index.tsx new file mode 100644 index 00000000000..052245261b1 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/payments/index.tsx @@ -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(""); + const [description, setDescription] = useState(""); + + return ( + <> + + {area === "Application" && ( + <> + + + + + )} + {(area === "Application" || area === "Dashboard") && ( + + )} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/tokens-marketplace/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/tokens-marketplace/index.tsx new file mode 100644 index 00000000000..89d80267649 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/tokens-marketplace/index.tsx @@ -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(""); + + return ( + <> + + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_DescriptionInput.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/shared/SupportForm_DescriptionInput.tsx similarity index 55% rename from apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_DescriptionInput.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/shared/SupportForm_DescriptionInput.tsx index 24bed8dd13f..684ae621cd7 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_DescriptionInput.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/shared/SupportForm_DescriptionInput.tsx @@ -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 ( -
-