From 36e6b09d45b30b957d09cb212e449596012b3a28 Mon Sep 17 00:00:00 2001 From: Yash <67926590+Yash094@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:28:53 +0530 Subject: [PATCH 01/18] support-in-dashboard v1 --- apps/dashboard/src/@/api/support.ts | 328 +++++ .../(team)/~/support/SupportLayout.tsx | 24 + .../support/_components/SupportAIChatCard.tsx | 60 + .../_components/SupportCasesClient.tsx | 1123 +++++++++++++++++ .../~/support/_components/SupportTabs.tsx | 64 + .../contact-forms/account/index.tsx | 29 + .../connect/AffectedAreaInput.tsx | 59 + .../contact-forms/connect/index.tsx | 133 ++ .../contact-forms/contracts/index.tsx | 133 ++ .../contact-forms/engine/index.tsx | 57 + .../_components/contact-forms/other/index.tsx | 53 + .../contact-forms/payments/index.tsx | 50 + .../tokens-marketplace/index.tsx | 24 + .../shared/SupportForm_DescriptionInput.tsx | 33 + .../shared/SupportForm_SelectInput.tsx | 60 + .../shared/SupportForm_TextInput.tsx | 36 + .../shared/SupportForm_UnityInput.tsx | 48 + .../[team_slug]/(team)/~/support/layout.tsx | 37 + .../[team_slug]/(team)/~/support/page.tsx | 40 + 19 files changed, 2391 insertions(+) create mode 100644 apps/dashboard/src/@/api/support.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/SupportLayout.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportAIChatCard.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCasesClient.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTabs.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/account/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/AffectedAreaInput.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/connect/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/contracts/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/engine/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/other/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/payments/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/contact-forms/tokens-marketplace/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/shared/SupportForm_DescriptionInput.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/shared/SupportForm_SelectInput.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/shared/SupportForm_TextInput.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/shared/SupportForm_UnityInput.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/page.tsx diff --git a/apps/dashboard/src/@/api/support.ts b/apps/dashboard/src/@/api/support.ts new file mode 100644 index 00000000000..286f793490c --- /dev/null +++ b/apps/dashboard/src/@/api/support.ts @@ -0,0 +1,328 @@ +"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; +} + +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 || []; + console.log("rawMessages", rawMessages); + // 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(); + 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(); + 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/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..2d7ac797ceb --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/SupportLayout.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export function SupportLayout(props: { children: React.ReactNode }) { + // If mobile navigation is needed in the future, add state and logic here. + const showFullNavOnMobile = true; + + return ( +
+ {/* Page content */} +
+
+ {props.children} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportAIChatCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportAIChatCard.tsx new file mode 100644 index 00000000000..a398c715f1d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportAIChatCard.tsx @@ -0,0 +1,60 @@ +"use client"; +import { BotIcon } from "lucide-react"; +import { useState } from "react"; + +export function SupportAIChatCard({ + _authToken, + _teamId, + onStartChat, +}: { + _authToken?: string; + _teamId?: string; + onStartChat: (message: string) => void; +}) { + const [message, setMessage] = useState(""); + + return ( +
+
+ + + +
+
Ask AI for support
+
+ + Online +
+
+
+
+
+ I’ll help you troubleshoot. If I can’t fix it, I’ll pass it to our + support team. +
+
+
+ setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey && message.trim()) { + e.preventDefault(); + onStartChat(message); + } + }} + placeholder="Type a message..." + value={message} + /> + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCasesClient.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCasesClient.tsx new file mode 100644 index 00000000000..1d786a70add --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCasesClient.tsx @@ -0,0 +1,1123 @@ +"use client"; + +import { format } from "date-fns"; +import { + ArrowRightIcon, + BotIcon, + LoaderCircleIcon, + SendIcon, +} from "lucide-react"; +import dynamic from "next/dynamic"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { SupportTicket } from "@/api/support"; +import { + createSupportTicket, + getSupportTicket, + sendMessageToTicket, +} from "@/api/support"; +import type { Team } from "@/api/team"; +import { MarkdownRenderer } from "@/components/blocks/markdown-renderer"; +import { Reasoning } from "@/components/chat/Reasoning"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { SupportAIChatCard } from "./SupportAIChatCard"; +import { SupportTabs } from "./SupportTabs"; +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 SupportCasesClientProps { + tickets: SupportTicket[]; + team: Team; + authToken?: string; +} + +export default function SupportCasesClient({ + tickets, + team, + authToken, +}: SupportCasesClientProps) { + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [selectedCaseDetails, setSelectedCaseDetails] = + useState(null); + const [isLoadingCaseDetails, setIsLoadingCaseDetails] = useState(false); + const [activeTab, setActiveTab] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [replyMessage, setReplyMessage] = useState(""); + const [isSubmittingReply, setIsSubmittingReply] = useState(false); + const messagesEndRef = useRef(null); + const [showAIChat, setShowAIChat] = useState(false); + 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 [_inputFocused, _setInputFocusedd] = useState(false); + const _router = useDashboardRouter(); + const replySectionRef = useRef(null); + + // Form states + const [showCreateForm, setShowCreateForm] = useState(false); + const [productLabel, setProductLabel] = useState(""); + const [isSubmittingForm, setIsSubmittingForm] = useState(false); + const formRef = useRef(null); + const _formContainerRef = useRef(null); + + const selectedCase = + selectedCaseDetails || tickets.find((c) => c.id === selectedCaseId); + + // Scroll to bottom when messages change + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (messagesEndRef.current && selectedCaseDetails?.messages) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [selectedCaseDetails?.messages]); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (chatContainerRef.current && chatMessages.length > 0) { + chatContainerRef.current.scrollTop = + chatContainerRef.current.scrollHeight; + } + }, [chatMessages]); + + // Scroll to show new form fields when product type changes or form updates + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (showCreateForm && chatContainerRef.current) { + const scrollToBottom = () => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: "smooth", + }); + } + }; + + // Set up a MutationObserver to watch for form changes + const formContainer = chatContainerRef.current.querySelector("form"); + if (formContainer) { + const observer = new MutationObserver((mutations) => { + let shouldScroll = false; + mutations.forEach((mutation) => { + if ( + mutation.type === "childList" && + mutation.addedNodes.length > 0 + ) { + // Check if any added nodes contain form fields + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + if ( + element.querySelector("input, textarea, select") || + element.matches("input, textarea, select") + ) { + shouldScroll = true; + } + } + }); + } + }); + + if (shouldScroll) { + setTimeout(scrollToBottom, 100); + } + }); + + observer.observe(formContainer, { + childList: true, + subtree: true, + }); + + // Initial scroll when form appears + scrollToBottom(); + setTimeout(scrollToBottom, 100); + setTimeout(scrollToBottom, 300); + + return () => observer.disconnect(); + } + } + }, [showCreateForm]); + + // Scroll to reply section when a case is selected + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (selectedCase && replySectionRef.current && chatContainerRef.current) { + const chat = chatContainerRef.current; + const reply = replySectionRef.current; + // Calculate offset of reply section relative to chat container + const chatRect = chat.getBoundingClientRect(); + const replyRect = reply.getBoundingClientRect(); + const offset = replyRect.top - chatRect.top + chat.scrollTop; + chat.scrollTo({ top: offset, behavior: "smooth" }); + } + }, [selectedCase]); + + // Scroll to bottom of AI chat when messages change + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (showAIChat && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [showAIChat]); + + 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 ?? "-")}\nName: ${String(team.name ?? "-")}\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}`; + } + + await createSupportTicket({ + message: formattedMessage, + teamSlug: team.slug, + title: `${productLabel} Issue - ${team.billingEmail} (${team.billingPlan})`, + }); + + // Add success message to chat + const successMsg = { + content: + "Great! Your support case has been created successfully. Our technical team will review it and get back to you soon. You can continue chatting with me if you have any other questions.", + id: Date.now(), + isUser: false, + timestamp: new Date().toISOString(), + isSuccessMessage: true, + }; + + setChatMessages((msgs) => [...msgs, successMsg]); + setShowCreateForm(false); + 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); + } + }; + + const handleSelectCase = async (ticketId: string) => { + setSelectedCaseId(ticketId); + setSelectedCaseDetails(null); + setIsLoadingCaseDetails(true); + + try { + const ticketDetails = await getSupportTicket( + ticketId, + team.slug, + authToken, + ); + if (ticketDetails) { + setSelectedCaseDetails(ticketDetails); + } + } catch (_error) { + toast.error("Failed to load ticket details"); + } finally { + setIsLoadingCaseDetails(false); + } + }; + + const handleBackToCases = () => { + setSelectedCaseId(null); + setSelectedCaseDetails(null); + setReplyMessage(""); + }; + + const handleSendReply = async () => { + if ( + !selectedCase || + !replyMessage.trim() || + !team.unthreadCustomerId || + !team.billingEmail + ) { + toast.error("Please enter a message"); + return; + } + + setIsSubmittingReply(true); + + try { + await sendMessageToTicket({ + message: replyMessage, + teamSlug: team.slug, + ticketId: selectedCase.id, + }); + + toast.success("Reply sent successfully!"); + setReplyMessage(""); + + // Force refresh the ticket details to show the new message + setSelectedCaseDetails(null); + setIsLoadingCaseDetails(true); + + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const ticketDetails = await getSupportTicket( + selectedCase.id, + team.slug, + authToken, + ); + if (ticketDetails) { + setSelectedCaseDetails(ticketDetails); + } + } catch (refreshError) { + console.error("Error refreshing ticket details:", refreshError); + } finally { + setIsLoadingCaseDetails(false); + } + } catch (_error) { + toast.error("Failed to send reply. Please try again."); + } finally { + setIsSubmittingReply(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + handleSendReply(); + } + }; + + // 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 = + (ticket.status as string) === "in_progress" || + (ticket.status as string) === "needs_response" || + (ticket.status as string) === "on_hold"; + break; + case "closed": + matchesTab = ticket.status === "resolved" || ticket.status === "closed"; + break; + default: + matchesTab = true; + } + + // Filter by search query + const matchesSearch = + searchQuery === "" || + ticket.id.toLowerCase().includes(searchQuery.toLowerCase()); + + return matchesTab && matchesSearch; + }); + + // Helper function to check if ticket matches search query + const matchesSearch = (ticket: SupportTicket) => { + return ( + searchQuery === "" || + ticket.id.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }; + + // Calculate counts for tabs + const counts = { + all: tickets.filter(matchesSearch).length, + closed: tickets.filter( + (ticket) => + (ticket.status === "resolved" || ticket.status === "closed") && + matchesSearch(ticket), + ).length, + open: tickets.filter( + (ticket) => + ((ticket.status as string) === "in_progress" || + (ticket.status as string) === "needs_response" || + (ticket.status as string) === "on_hold") && + matchesSearch(ticket), + ).length, + }; + + const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "resolved": + case "closed": + return "border-gray-500 text-gray-500 bg-gray-500/10"; + case "in_progress": + return "border-red-500 text-red-500 bg-red-500/10"; + case "needs_response": + return "border-yellow-500 text-yellow-500 bg-yellow-500/10"; + case "on_hold": + return "border-purple-500 text-purple-500 bg-purple-500/10"; + default: + return "border-yellow-500 text-yellow-500 bg-yellow-500/10"; + } + }; + + const getStatusLabel = (status: string, _ticket?: SupportTicket) => { + 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"; + } + }; + + async function sendMessageToSiwa( + message: string, + currentConversationId?: string, + ) { + 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 !== conversationId) { + setConversationId(data.conversationId); + } + return data.data; + } + + async function handleStartChat(initialMessage: string) { + setShowAIChat(true); + // Update AI greeting message + const initialAIMsg = { + content: + "Hi! I’m thirdweb’s AI assistant — I’ll help you troubleshoot. If I can’t fix it, I’ll pass it to our support", + id: Date.now(), + isUser: false, + timestamp: new Date().toISOString(), + }; + 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(), + }, + ]); + setChatInput(""); + try { + const aiResponse = await sendMessageToSiwa(initialMessage); + setChatMessages([ + initialAIMsg, + userMsg, + { + content: aiResponse, + id: Date.now() + 3, + isUser: false, + timestamp: new Date().toISOString(), + }, + ]); + } catch (_error) { + setChatMessages([ + initialAIMsg, + userMsg, + { + content: "Sorry, something went wrong. Please try again.", + id: Date.now() + 4, + isUser: false, + timestamp: new Date().toISOString(), + }, + ]); + } + } + + async function handleChatSend() { + if (!chatInput.trim()) return; + const userMsg = { + content: chatInput, + id: Date.now(), + isUser: true, + timestamp: new Date().toISOString(), + }; + setChatMessages((msgs) => [ + ...msgs, + userMsg, + { + content: "__reasoning__", + id: Date.now() + 1, + isUser: false, + timestamp: new Date().toISOString(), + }, + ]); + setChatInput(""); + try { + const aiResponse = await sendMessageToSiwa(chatInput, conversationId); + setChatMessages((msgs) => [ + ...msgs.slice(0, -1), // remove loading + { + content: aiResponse, + id: Date.now() + 2, + isUser: false, + timestamp: new Date().toISOString(), + }, + ]); + } catch (_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(), + }, + ]); + } + } + + function handleChatKeyPress(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleChatSend(); + } + } + + if (showAIChat) { + return ( +
+
+
+ +
+ +
+
+ {/* Chat Header */} +
+
+ +
+
+
+

Ask AI for support

+

Online

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

+ {message.content} +

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

+ {format(new Date(message.timestamp), "h:mm a")} +

+ + {/* Show Back to Support Portal button for success message */} + {!message.isUser && message.isSuccessMessage && ( +
+ +
+ )} + + {/* Show Create Support Case button in the AI response (index 2) - only if form not shown */} + {!message.isUser && + index === chatMessages.length - 1 && + !showCreateForm && + !message.isSuccessMessage && + message.content !== "__reasoning__" && ( +
+ +
+ )} + + {/* 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. +

+
+ +
+ + + {/* Submit Buttons */} +
+ + +
+ +
+ )} +
+
+ ))} +
+
+ + {/* Chat Input */} + {!showCreateForm && ( +
+
+