From d5ff9f2f5d15e8287967451f4e2c187b2d2500fb Mon Sep 17 00:00:00 2001 From: GiselleNessi Date: Fri, 29 Aug 2025 15:27:15 +0000 Subject: [PATCH] feat: add support siwa feedback system for closed support tickets (#7916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR introduces a feedback system for support tickets, allowing users to submit ratings and comments. It includes API calls for submitting feedback and checking its status, along with UI updates to handle user interactions and display feedback status. ### Detailed summary - Added `submitSupportFeedback` function to submit feedback. - Added `checkFeedbackStatus` function to verify if feedback has been submitted. - Integrated feedback submission UI in `SupportCaseDetails` component. - Implemented rating selection using stars. - Added input field for optional feedback comments. - Displayed loading and error states for feedback status checks. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit * **New Features** * 5‑star rating UI and optional comment for closed support cases, with Send Feedback button, “Checking feedback status…” and sending spinners, disabled send until a rating is chosen, and a thank‑you message after submission. * **Improvements** * Server-backed feedback status check and submission with input validation, 1–5 rating, 1000‑char comment limit, 10s request timeout, and contextual success/error toasts. * Accessible star controls with ARIA labels and filled‑star visuals. --- .../_components/SupportCaseDetails.tsx | 158 +++++++++++++++++- .../(team)/~/support/apis/feedback.ts | 123 ++++++++++++++ 2 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts 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" }; + } +}