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 index 8c1c66e7b50..645d8244aa4 100644 --- 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 @@ -1,7 +1,8 @@ "use client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; -import { ChevronDownIcon, UserIcon } from "lucide-react"; +import { ChevronDownIcon, StarIcon, UserIcon } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; @@ -20,6 +21,7 @@ import { Spinner } from "@/components/ui/Spinner"; import { AutoResizeTextarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo"; +import { checkFeedbackStatus, submitSupportFeedback } from "../apis/feedback"; import { sendMessageToTicket } from "../apis/support"; import type { SupportMessage, SupportTicket } from "../types/tickets"; import { @@ -37,6 +39,77 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { const [isSubmittingReply, setIsSubmittingReply] = useState(false); const [localMessages, setLocalMessages] = useState(ticket.messages || []); + // rating/feedback + const [rating, setRating] = useState(0); + const [feedback, setFeedback] = useState(""); + + // Check if feedback has already been submitted for this ticket + const feedbackStatusQuery = useQuery({ + queryKey: ["feedbackStatus", ticket.id], + queryFn: async () => { + const result = await checkFeedbackStatus(ticket.id); + if ("error" in result) { + throw new Error(result.error); + } + return result.hasFeedback; + }, + enabled: ticket.status === "closed", + staleTime: 60_000, + gcTime: 5 * 60_000, + }); + + const feedbackSubmitted = feedbackStatusQuery.data ?? false; + const isLoading = feedbackStatusQuery.isLoading; + const hasError = feedbackStatusQuery.isError; + + const handleStarClick = (starIndex: number) => { + setRating(starIndex + 1); + }; + + const queryClient = useQueryClient(); + const submitFeedbackMutation = useMutation({ + mutationFn: async () => { + if (rating === 0) { + throw new Error("Please select a rating"); + } + const result = await submitSupportFeedback({ + rating, + feedback, + ticketId: ticket.id, + }); + if ("error" in result) { + throw new Error(result.error); + } + return result; + }, + onSuccess: () => { + toast.success("Thank you for your feedback!"); + setRating(0); + setFeedback(""); + // mark as submitted immediately + queryClient.setQueryData(["feedbackStatus", ticket.id], true); + }, + onError: (err) => { + console.error("Failed to submit feedback:", err); + const msg = err instanceof Error ? err.message : String(err ?? ""); + let message = "Failed to submit feedback. Please try again."; + if (/network|fetch/i.test(msg)) { + message = "Network error. Please check your connection and try again."; + } else if ( + /validation|Rating must be|Please select a rating/i.test(msg) + ) { + message = msg; // show precise user-facing validation error + } else if (/API Server error/i.test(msg)) { + message = "Server error. Please try again later."; + } + toast.error(message); + }, + }); + + const handleSendFeedback = () => { + submitFeedbackMutation.mutate(); + }; + const handleSendReply = async () => { if (!team.unthreadCustomerId) { toast.error("No unthread customer id found for this team"); @@ -149,11 +222,88 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { )} - {ticket.status === "closed" && ( + {ticket.status === "closed" && isLoading && ( +
+
+ + + Checking feedback status... + +
+
+ )} + + {ticket.status === "closed" && !isLoading && !feedbackSubmitted && ( +
+

+ This ticket is closed. Give us a quick rating to let us know how + we did! +

+ {hasError && ( +

+ Couldn't verify prior feedback right now — you can still submit + a rating. +

+ )} + +
+ {[1, 2, 3, 4, 5].map((starValue) => ( + + ))} +
+ +
+ setFeedback(e.target.value)} + placeholder="Optional: Tell us how we can improve." + maxLength={1000} + className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground" + /> + +
+
+ )} + + {ticket.status === "closed" && feedbackSubmitted && (

- This ticket is closed. If you need further assistance, please - create a new ticket. + Thank you for your feedback! We appreciate your input and will use + it to improve our service.

)} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts new file mode 100644 index 00000000000..ed8dd57c58f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts @@ -0,0 +1,123 @@ +"use server"; + +type FeedbackSubmitResult = { success: true } | { error: string }; +type FeedbackStatusResult = { hasFeedback: boolean } | { error: string }; + +export async function submitSupportFeedback({ + rating, + feedback, + ticketId, +}: { + rating: number; + feedback?: string; + ticketId: string; +}): Promise { + try { + // Fail fast on missing configuration + const siwaUrl = + process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? ""; + + if (!siwaUrl) { + throw new Error("SIWA URL not configured"); + } + + const apiKey = process.env.SERVICE_AUTH_KEY_SIWA; + + if (!apiKey) { + throw new Error("SERVICE_AUTH_KEY_SIWA not configured"); + } + + // Basic input validation/normalization + if (!ticketId?.trim()) { + return { error: "ticketId is required." }; + } + + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + return { error: "Rating must be an integer between 1 and 5." }; + } + + const normalizedFeedback = (feedback ?? "") + .toString() + .trim() + .slice(0, 1000); // hard cap length + + const payload = { + rating: rating.toString(), + feedback: normalizedFeedback, + ticket_id: ticketId, + }; + + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 10_000); + const response = await fetch(`${siwaUrl}/v1/csat/saveCSATFeedback`, { + method: "POST", + cache: "no-store", + headers: { + "Content-Type": "application/json", + "x-service-api-key": apiKey, + }, + body: JSON.stringify(payload), + signal: ac.signal, + }).finally(() => clearTimeout(t)); + + if (!response.ok) { + const errorText = await response.text(); + return { error: `API Server error: ${response.status} - ${errorText}` }; + } + + return { success: true }; + } catch (error) { + console.error("Feedback submission error:", error); + return { error: "Internal server error" }; + } +} + +export async function checkFeedbackStatus( + ticketId: string, +): Promise { + try { + // Basic input validation + if (!ticketId?.trim()) { + return { error: "ticketId is required." }; + } + + // Fail fast on missing configuration + const siwaUrl = + process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? ""; + + if (!siwaUrl) { + throw new Error("SIWA URL not configured"); + } + + const apiKey = process.env.SERVICE_AUTH_KEY_SIWA; + + if (!apiKey) { + throw new Error("SERVICE_AUTH_KEY_SIWA not configured"); + } + + const fullUrl = `${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent(ticketId)}`; + + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 10_000); + const response = await fetch(fullUrl, { + method: "GET", + cache: "no-store", + headers: { + "Content-Type": "application/json", + "x-service-api-key": apiKey, + }, + signal: ac.signal, + }).finally(() => clearTimeout(t)); + + if (!response.ok) { + const errorText = await response.text(); + return { error: `API Server error: ${response.status} - ${errorText}` }; + } + + const data = await response.json(); + return { hasFeedback: data.has_feedback }; + } catch (error) { + console.error("Feedback status check error:", error); + return { error: "Internal server error" }; + } +}