diff --git a/apps/dashboard/src/@/api/support.ts b/apps/dashboard/src/@/api/support.ts new file mode 100644 index 00000000000..998b2d5a700 --- /dev/null +++ b/apps/dashboard/src/@/api/support.ts @@ -0,0 +1,354 @@ +"use server"; +import "server-only"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token"; + +export interface SupportTicket { + id: string; + status: "needs_response" | "in_progress" | "on_hold" | "closed" | "resolved"; + createdAt: string; + updatedAt: string; + messages?: SupportMessage[]; +} + +interface SupportMessage { + id: string; + content: string; + createdAt: string; + timestamp: string; + author?: { + name: string; + email: string; + type: "user" | "customer"; + }; +} + +interface CreateSupportTicketRequest { + message: string; + teamSlug: string; + title: string; + conversationId?: string; +} + +interface SendMessageRequest { + ticketId: string; + teamSlug: string; + message: string; +} + +export async function getSupportTicketsByTeam( + teamSlug: string, + authToken?: string, +): Promise { + if (!teamSlug) { + throw new Error("Team slug is required to fetch support tickets"); + } + + const token = authToken || (await getAuthToken()); + if (!token) { + throw new Error("No auth token available"); + } + + // URL encode the team slug to handle special characters like # + const encodedTeamSlug = encodeURIComponent(teamSlug); + const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/list`; + + // Build the POST payload according to API spec + const payload = { + limit: 50, + descending: true, + }; + + const response = await fetch(apiUrl, { + body: JSON.stringify(payload), + cache: "no-store", + headers: { + Accept: "application/json", + "Accept-Encoding": "identity", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API Server error: ${response.status} - ${errorText}`); + } + const data: { data?: SupportTicket[] } = await response.json(); + const conversations = data.data || []; + return conversations; +} + +interface RawSupportMessage { + id: string; + text?: string; + timestamp?: string; + createdAt?: string; + isPrivateNote?: boolean; + sentByUser?: { + name: string; + email: string; + isExternal: boolean; + }; + // Add any other fields you use from the API +} + +export async function getSupportTicket( + ticketId: string, + teamSlug: string, + authToken?: string, +): Promise { + if (!ticketId || !teamSlug) { + throw new Error("Ticket ID and team slug are required"); + } + + const token = authToken || (await getAuthToken()); + if (!token) { + throw new Error("No auth token available"); + } + + // URL encode the team slug to handle special characters like # + const encodedTeamSlug = encodeURIComponent(teamSlug); + const encodedTicketId = encodeURIComponent(ticketId); + + const messagesPayload = { + limit: 100, + descending: false, + }; + + // Fetch conversation details and messages in parallel + const [conversationResponse, messagesResponse] = await Promise.all([ + fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}`, + { + cache: "no-store", + headers: { + Accept: "application/json", + "Accept-Encoding": "identity", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "GET", + }, + ), + fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages/list`, + { + body: JSON.stringify(messagesPayload), + cache: "no-store", + headers: { + Accept: "application/json", + "Accept-Encoding": "identity", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ), + ]); + + if (!conversationResponse.ok) { + if (conversationResponse.status === 404) { + return null; // Ticket not found + } + const errorText = await conversationResponse.text(); + throw new Error( + `API Server error: ${conversationResponse.status} - ${errorText}`, + ); + } + + const conversation: SupportTicket = await conversationResponse.json(); + + // Fetch and map messages if the messages request was successful + if (messagesResponse.ok) { + const messagesData: { data?: unknown[] } = await messagesResponse.json(); + const rawMessages = messagesData.data || []; + // Transform the raw messages to match our interface + const messages: SupportMessage[] = (rawMessages as RawSupportMessage[]) + .filter((msg) => { + // Filter out messages without content - check both text and text fields + const hasContent = msg.text && msg.text.length > 0; + const hasText = msg.text && msg.text.trim().length > 0; + // Filter out private notes - they should not be shown to customers + const isNotPrivateNote = !msg.isPrivateNote; + return (hasContent || hasText) && isNotPrivateNote; + }) + .map((msg) => { + // Use text if available and is a non-empty array, otherwise fall back to text + let content = ""; + if (typeof msg.text === "string" && msg.text.trim().length > 0) { + content = msg.text; + } + + // Clean up 'Email:' line to show only the plain email if it contains a mailto link + if (content) { + content = content + .split("\n") + .map((line) => { + if (line.trim().toLowerCase().startsWith("email:")) { + // Extract email from + const match = line.match(/]+)\|[^>]+>/); + if (match) { + return `Email: ${match[1]}`; + } + } + return line; + }) + .join("\n"); + } + + // Map the author information from sentByUser if available + const author = msg.sentByUser + ? { + name: msg.sentByUser.name, + email: msg.sentByUser.email, + type: (msg.sentByUser.isExternal ? "customer" : "user") as + | "user" + | "customer", + } + : undefined; + + return { + id: msg.id, + content: content, + createdAt: msg.timestamp || msg.createdAt || "", + timestamp: msg.timestamp || msg.createdAt || "", + author: author, + }; + }); + + conversation.messages = messages; + } else { + // Don't throw error, just leave messages empty + const errorText = await messagesResponse.text(); + console.error("Failed to fetch messages:", errorText); + conversation.messages = []; + } + + return conversation; +} + +export async function createSupportTicket( + request: CreateSupportTicketRequest, +): Promise { + if (!request.teamSlug) { + throw new Error("Team slug is required to create support ticket"); + } + + const token = await getAuthToken(); + if (!token) { + throw new Error("No auth token available"); + } + + // Fetch wallet address (server-side) + const walletAddress = await getAuthTokenWalletAddress(); + + // URL encode the team slug to handle special characters like # + const encodedTeamSlug = encodeURIComponent(request.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 = request.message; + if (!message.includes("Wallet address:")) { + message = `Wallet address: ${String(walletAddress || "-")}\n${message}`; + } + + const payload = { + markdown: message.trim(), + title: request.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(); + throw new Error(`API Server error: ${response.status} - ${errorText}`); + } + + const createdConversation: SupportTicket = await response.json(); + + // Escalate to SIWA feedback endpoint if conversationId is provided + if (request.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}`, + ...(request.teamSlug ? { "x-team-id": request.teamSlug } : {}), + }, + body: JSON.stringify({ + conversationId: request.conversationId, + feedbackRating: 9999, + }), + }); + } + } catch (error) { + // Log error but don't fail the ticket creation + console.error("Failed to escalate to SIWA feedback:", error); + } + } + + return createdConversation; +} + +export async function sendMessageToTicket( + request: SendMessageRequest, +): Promise { + if (!request.ticketId || !request.teamSlug) { + throw new Error("Ticket ID and team slug are required"); + } + + const token = await getAuthToken(); + if (!token) { + throw new Error("No auth token available"); + } + + // URL encode the team slug and ticket ID to handle special characters like # + const encodedTeamSlug = encodeURIComponent(request.teamSlug); + const encodedTicketId = encodeURIComponent(request.ticketId); + const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`; + + // Build the payload for sending a message + // 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", + }; + + const response = await fetch(apiUrl, { + body, + headers, + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API Server error: ${response.status} - ${errorText}`); + } + // Message sent successfully, no need to return anything +} diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts index f8eeb3f43da..e30cf78095d 100644 --- a/apps/dashboard/src/@/api/team.ts +++ b/apps/dashboard/src/@/api/team.ts @@ -48,10 +48,6 @@ export async function service_getTeamBySlug(slug: string) { return null; } -export function getTeamById(id: string) { - return getTeamBySlug(id); -} - export async function getTeams() { const token = await getAuthToken(); if (!token) { @@ -97,7 +93,7 @@ export async function getLastVisitedTeam() { const lastVisitedTeamId = cookiesStore.get(LAST_USED_TEAM_ID)?.value; if (lastVisitedTeamId) { - const team = await getTeamById(lastVisitedTeamId); + const team = await getTeamBySlug(lastVisitedTeamId); if (team) { return team; } diff --git a/apps/dashboard/src/@/components/chat/CustomChatButton.tsx b/apps/dashboard/src/@/components/chat/CustomChatButton.tsx index c6ebb0bf673..b6eb475792b 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"; @@ -21,7 +22,7 @@ export function CustomChatButton(props: { label: string; examplePrompts: string[]; authToken: string | undefined; - teamId: string | undefined; + team: Team; // changed from teamId clientId: string | undefined; requireLogin?: boolean; }) { @@ -82,7 +83,7 @@ export function CustomChatButton(props: { }))} networks={props.networks} requireLogin={props.requireLogin} - teamId={props.teamId} + team={props.team} // pass full team object /> )} diff --git a/apps/dashboard/src/@/components/chat/CustomChatContent.tsx b/apps/dashboard/src/@/components/chat/CustomChatContent.tsx index 6d46e6e0092..d0cb45d8964 100644 --- a/apps/dashboard/src/@/components/chat/CustomChatContent.tsx +++ b/apps/dashboard/src/@/components/chat/CustomChatContent.tsx @@ -5,6 +5,7 @@ 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 { ChatBar } from "./ChatBar"; @@ -14,7 +15,7 @@ import type { ExamplePrompt, NebulaContext } from "./types"; export default function CustomChatContent(props: { authToken: string | undefined; - teamId: string | undefined; + team: Team; clientId: string | undefined; client: ThirdwebClient; examplePrompts: ExamplePrompt[]; @@ -31,14 +32,14 @@ export default function CustomChatContent(props: { 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 +56,10 @@ function CustomChatContentLoggedIn(props: { const [enableAutoScroll, setEnableAutoScroll] = useState(false); const connectionStatus = useActiveWalletConnectionStatus(); + // Support form state + const [showSupportForm, setShowSupportForm] = useState(false); + const [productLabel, setProductLabel] = useState(""); + const handleSendMessage = useCallback( async (userMessage: UserMessage) => { const abortController = new AbortController(); @@ -96,7 +101,7 @@ function CustomChatContentLoggedIn(props: { headers: { Authorization: `Bearer ${props.authToken}`, "Content-Type": "application/json", - ...(props.teamId ? { "x-team-id": props.teamId } : {}), + ...(props.team.id ? { "x-team-id": props.team.id } : {}), ...(props.clientId ? { "x-client-id": props.clientId } : {}), }, method: "POST", @@ -132,7 +137,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 +170,7 @@ function CustomChatContentLoggedIn(props: { headers: { Authorization: `Bearer ${props.authToken}`, "Content-Type": "application/json", - ...(props.teamId ? { "x-team-id": props.teamId } : {}), + ...(props.team.id ? { "x-team-id": props.team.id } : {}), }, method: "POST", }); @@ -188,7 +193,7 @@ function CustomChatContentLoggedIn(props: { // Consider implementing retry logic here } }, - [sessionId, props.authToken, props.teamId, messages], + [sessionId, props.authToken, props.team.id, messages], ); const showEmptyState = !userHasSubmittedMessage && messages.length === 0; @@ -212,8 +217,14 @@ function CustomChatContentLoggedIn(props: { sessionId={sessionId} setEnableAutoScroll={setEnableAutoScroll} useSmallText + showSupportForm={showSupportForm} + setShowSupportForm={setShowSupportForm} + productLabel={productLabel} + setProductLabel={setProductLabel} + team={props.team} /> )} + {/* Removed floating support case button and form */} { chatAbortController?.abort(); diff --git a/apps/dashboard/src/@/components/chat/CustomChats.tsx b/apps/dashboard/src/@/components/chat/CustomChats.tsx index d3953f18f7b..65018b471b5 100644 --- a/apps/dashboard/src/@/components/chat/CustomChats.tsx +++ b/apps/dashboard/src/@/components/chat/CustomChats.tsx @@ -1,14 +1,18 @@ import { AlertCircleIcon, + ArrowRightIcon, MessageCircleIcon, 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 { Button } from "@/components/ui/button"; import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; import { cn } from "@/lib/utils"; +import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm"; import { Reasoning } from "./Reasoning"; // Define local types @@ -47,10 +51,18 @@ export function CustomChats(props: { useSmallText?: boolean; sendMessage: (message: UserMessage) => void; onFeedback?: (messageIndex: number, feedback: 1 | -1) => void; + // New props for support form + showSupportForm: boolean; + setShowSupportForm: (v: boolean) => void; + productLabel: string; + setProductLabel: (v: string) => void; + team: Team; }) { const { messages, setEnableAutoScroll, enableAutoScroll } = props; const scrollAnchorRef = useRef(null); const chatContainerRef = useRef(null); + // Add state to track if a support ticket was created + const [supportTicketCreated, setSupportTicketCreated] = useState(false); // auto scroll to bottom when messages change // eslint-disable-next-line no-restricted-syntax @@ -123,6 +135,68 @@ 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 */} + {!props.showSupportForm && !supportTicketCreated && ( +
+ +
+ )} + {/* Show form if open and ticket not created */} + {props.showSupportForm && !supportTicketCreated && ( +
+ { + props.setShowSupportForm(false); + props.setProductLabel(""); + setSupportTicketCreated(true); + }} + /> +
+ )} + {/* Show success message if ticket created */} + {supportTicketCreated && ( +
+
+ Your support ticket has been created! Our team + will get back to you soon. +
+ +
+ )} + + )} ); })} diff --git a/apps/dashboard/src/@/lib/support-utils.ts b/apps/dashboard/src/@/lib/support-utils.ts new file mode 100644 index 00000000000..cbe62bbe04f --- /dev/null +++ b/apps/dashboard/src/@/lib/support-utils.ts @@ -0,0 +1,44 @@ +/** + * Shared utility functions for support ticket status handling + */ + +/** + * Get the CSS classes for a support ticket status badge + */ +export function getStatusColor(status: string): string { + switch (status.toLowerCase()) { + case "resolved": + case "closed": + return "border-muted text-muted-foreground bg-muted/10"; + case "in_progress": + return "border-destructive text-destructive bg-destructive/10"; + case "needs_response": + return "warning"; + case "on_hold": + return "border-secondary text-secondary-foreground bg-secondary/10"; + default: + return "warning"; + } +} + +/** + * Get the display label for a support ticket status + */ +export function getStatusLabel(status: string): string { + const statusLower = status.toLowerCase(); + + switch (statusLower) { + case "closed": + return "Closed"; + case "resolved": + return "Resolved"; + case "in_progress": + return "Needs Response"; + case "needs_response": + return "In Progress"; + case "on_hold": + return "On Hold"; + default: + return "In Progress"; + } +} 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/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 index bce10cb8765..c3f8e8be640 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx @@ -71,7 +71,7 @@ export default async function SupportPage() { ]); const teams = await getTeams(); - const teamId = teams?.[0]?.id ?? undefined; + const team = teams?.[0]; return (
@@ -92,21 +92,25 @@ export default async function SupportPage() { team.

- + {team && ( + + )} 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..4a0b12adccf 100644 --- a/apps/dashboard/src/app/(app)/components/Header/SecondaryNav/SecondaryNav.tsx +++ b/apps/dashboard/src/app/(app)/components/Header/SecondaryNav/SecondaryNav.tsx @@ -46,7 +46,7 @@ export function SecondaryNavLinks() { 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..4e4f3574827 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 @@ -103,19 +103,21 @@ export default async function TeamLayout(props: { > {props.children} -
- -
+ {team && ( +
+ +
+ )}
); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/SupportLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/SupportLayout.tsx new file mode 100644 index 00000000000..ddbf5ecaddf --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/SupportLayout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface SupportLayoutProps { + children: React.ReactNode; + className?: string; +} + +export function SupportLayout(props: SupportLayoutProps): React.ReactElement { + return ( +
+ {/* Page content */} +
+
{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..5422aca324e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/CreateSupportCase.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { ArrowLeftIcon, BotIcon, SendIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Team } from "@/api/team"; +import { MarkdownRenderer } from "@/components/blocks/markdown-renderer"; +import { Reasoning } from "@/components/chat/Reasoning"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { SupportHeader } from "./SupportHeader"; +import { SupportTicketForm } from "./SupportTicketForm"; + +interface CreateSupportCaseProps { + team: Team; + authToken?: string; +} + +export function CreateSupportCase({ team, authToken }: CreateSupportCaseProps) { + const [chatMessages, setChatMessages] = useState< + { + id: number; + content: string; + isUser: boolean; + timestamp: string; + isSuccessMessage?: boolean; + }[] + >([]); + const [chatInput, setChatInput] = useState(""); + const [conversationId, setConversationId] = useState( + undefined, + ); + const chatContainerRef = useRef(null); + const messagesEndRef = useRef(null); + const router = useDashboardRouter(); + + // Form states + const [showCreateForm, setShowCreateForm] = useState(false); + const [productLabel, setProductLabel] = useState(""); + + // Auto scroll to bottom when AI chat messages change + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (chatContainerRef.current && chatMessages.length > 0) { + chatContainerRef.current.scrollTop = + chatContainerRef.current.scrollHeight; + } + }, [chatMessages]); + + const handleBackToSupport = () => { + router.push(`/team/${team.slug}/~/support`); + }; + + // 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 _handleStartChat = useCallback( + async (initialMessage?: string) => { + // Show initial AI greeting message immediately + const initialAIMsg = { + 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(), + }; + + setChatMessages([initialAIMsg]); + setChatInput(""); + + // If there's an initial message from the user, add it and get AI response + if (initialMessage) { + const userMsg = { + content: initialMessage, + id: Date.now() + 1, + isUser: true, + timestamp: new Date().toISOString(), + }; + + setChatMessages([ + initialAIMsg, + userMsg, + { + content: "__reasoning__", + id: Date.now() + 2, + isUser: false, + timestamp: new Date().toISOString(), + }, + ]); + + try { + const aiResponse = await sendMessageToSiwa(initialMessage); + setChatMessages([ + initialAIMsg, + userMsg, + { + content: aiResponse, + id: Date.now() + 3, + isUser: false, + timestamp: new Date().toISOString(), + }, + ]); + } catch (error) { + console.error("Error in handleStartChat:", error); + setChatMessages([ + initialAIMsg, + userMsg, + { + content: "Sorry, something went wrong. Please try again.", + id: Date.now() + 4, + isUser: false, + timestamp: new Date().toISOString(), + }, + ]); + } + } + }, + [sendMessageToSiwa], + ); + + 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], + ); + + // Start the chat when component mounts + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + _handleStartChat(); + }, [_handleStartChat]); + + return ( +
+ {}} + /> + +
+
+ +
+ +
+
+ {/* Chat Header */} +
+
+ +
+
+
+

+ Ask AI for support +

+

Online

+
+
+ + {/* Chat Messages */} +
+ {chatMessages.map((message, index) => ( +
+
+ {message.isUser ? ( +

+ {message.content} +

+ ) : message.content === "__reasoning__" ? ( + + ) : ( + + )} +

+ {new Date(message.timestamp).toLocaleTimeString()} +

+ + {/* Show Back to Support button for success message */} + {!message.isUser && message.isSuccessMessage && ( +
+ +
+ )} + + {/* Show Create Support Case button in the AI response - only if form not shown and after user interaction */} + {!message.isUser && + index === chatMessages.length - 1 && + !showCreateForm && + !message.isSuccessMessage && + message.content !== "__reasoning__" && + chatMessages.length > 2 && ( +
+ +
+ )} + + {/* Show Support Case Form in the same message bubble when button is clicked */} + {!message.isUser && + index === chatMessages.length - 1 && + showCreateForm && + message.content !== "__reasoning__" && ( +
+
+

+ 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, + }, + ]); + }} + /> +
+ )} +
+
+ ))} +
+
+ + {/* Chat Input */} + {!showCreateForm && ( +
+
+