Skip to content

Commit d5ff9f2

Browse files
committed
feat: add support siwa feedback system for closed support tickets (#7916)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## 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}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 76694f0 commit d5ff9f2

File tree

2 files changed

+277
-4
lines changed

2 files changed

+277
-4
lines changed

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
34
import { format } from "date-fns";
4-
import { ChevronDownIcon, UserIcon } from "lucide-react";
5+
import { ChevronDownIcon, StarIcon, UserIcon } from "lucide-react";
56
import Link from "next/link";
67
import { useState } from "react";
78
import { toast } from "sonner";
@@ -20,6 +21,7 @@ import { Spinner } from "@/components/ui/Spinner";
2021
import { AutoResizeTextarea } from "@/components/ui/textarea";
2122
import { cn } from "@/lib/utils";
2223
import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo";
24+
import { checkFeedbackStatus, submitSupportFeedback } from "../apis/feedback";
2325
import { sendMessageToTicket } from "../apis/support";
2426
import type { SupportMessage, SupportTicket } from "../types/tickets";
2527
import {
@@ -37,6 +39,77 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
3739
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
3840
const [localMessages, setLocalMessages] = useState(ticket.messages || []);
3941

42+
// rating/feedback
43+
const [rating, setRating] = useState(0);
44+
const [feedback, setFeedback] = useState("");
45+
46+
// Check if feedback has already been submitted for this ticket
47+
const feedbackStatusQuery = useQuery({
48+
queryKey: ["feedbackStatus", ticket.id],
49+
queryFn: async () => {
50+
const result = await checkFeedbackStatus(ticket.id);
51+
if ("error" in result) {
52+
throw new Error(result.error);
53+
}
54+
return result.hasFeedback;
55+
},
56+
enabled: ticket.status === "closed",
57+
staleTime: 60_000,
58+
gcTime: 5 * 60_000,
59+
});
60+
61+
const feedbackSubmitted = feedbackStatusQuery.data ?? false;
62+
const isLoading = feedbackStatusQuery.isLoading;
63+
const hasError = feedbackStatusQuery.isError;
64+
65+
const handleStarClick = (starIndex: number) => {
66+
setRating(starIndex + 1);
67+
};
68+
69+
const queryClient = useQueryClient();
70+
const submitFeedbackMutation = useMutation({
71+
mutationFn: async () => {
72+
if (rating === 0) {
73+
throw new Error("Please select a rating");
74+
}
75+
const result = await submitSupportFeedback({
76+
rating,
77+
feedback,
78+
ticketId: ticket.id,
79+
});
80+
if ("error" in result) {
81+
throw new Error(result.error);
82+
}
83+
return result;
84+
},
85+
onSuccess: () => {
86+
toast.success("Thank you for your feedback!");
87+
setRating(0);
88+
setFeedback("");
89+
// mark as submitted immediately
90+
queryClient.setQueryData(["feedbackStatus", ticket.id], true);
91+
},
92+
onError: (err) => {
93+
console.error("Failed to submit feedback:", err);
94+
const msg = err instanceof Error ? err.message : String(err ?? "");
95+
let message = "Failed to submit feedback. Please try again.";
96+
if (/network|fetch/i.test(msg)) {
97+
message = "Network error. Please check your connection and try again.";
98+
} else if (
99+
/validation|Rating must be|Please select a rating/i.test(msg)
100+
) {
101+
message = msg; // show precise user-facing validation error
102+
} else if (/API Server error/i.test(msg)) {
103+
message = "Server error. Please try again later.";
104+
}
105+
toast.error(message);
106+
},
107+
});
108+
109+
const handleSendFeedback = () => {
110+
submitFeedbackMutation.mutate();
111+
};
112+
40113
const handleSendReply = async () => {
41114
if (!team.unthreadCustomerId) {
42115
toast.error("No unthread customer id found for this team");
@@ -149,11 +222,88 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
149222
)}
150223
</div>
151224

152-
{ticket.status === "closed" && (
225+
{ticket.status === "closed" && isLoading && (
226+
<div className="border-t p-6">
227+
<div className="flex items-center gap-2">
228+
<Spinner className="size-4" />
229+
<span className="text-muted-foreground text-sm">
230+
Checking feedback status...
231+
</span>
232+
</div>
233+
</div>
234+
)}
235+
236+
{ticket.status === "closed" && !isLoading && !feedbackSubmitted && (
237+
<div className="border-t p-6">
238+
<p className="text-muted-foreground text-sm">
239+
This ticket is closed. Give us a quick rating to let us know how
240+
we did!
241+
</p>
242+
{hasError && (
243+
<p className="text-destructive text-xs mt-2">
244+
Couldn't verify prior feedback right now — you can still submit
245+
a rating.
246+
</p>
247+
)}
248+
249+
<div className="flex gap-2 mb-6 mt-4">
250+
{[1, 2, 3, 4, 5].map((starValue) => (
251+
<button
252+
key={`star-${starValue}`}
253+
type="button"
254+
onClick={() => handleStarClick(starValue - 1)}
255+
className="transition-colors"
256+
aria-label={`Rate ${starValue} out of 5 stars`}
257+
>
258+
<StarIcon
259+
size={32}
260+
className={cn(
261+
"transition-colors",
262+
starValue <= rating
263+
? "text-pink-500 fill-current stroke-current"
264+
: "text-muted-foreground fill-current stroke-current",
265+
"hover:text-pink-500",
266+
)}
267+
strokeWidth={starValue <= rating ? 2 : 1}
268+
/>
269+
</button>
270+
))}
271+
</div>
272+
273+
<div className="relative">
274+
<AutoResizeTextarea
275+
value={feedback}
276+
onChange={(e) => setFeedback(e.target.value)}
277+
placeholder="Optional: Tell us how we can improve."
278+
maxLength={1000}
279+
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"
280+
/>
281+
<Button
282+
type="button"
283+
onClick={handleSendFeedback}
284+
disabled={submitFeedbackMutation.isPending || rating === 0}
285+
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
286+
variant="secondary"
287+
size="sm"
288+
>
289+
{submitFeedbackMutation.isPending ? (
290+
<>
291+
<Spinner className="size-4 mr-2" />
292+
Sending...
293+
</>
294+
) : (
295+
"Send Feedback"
296+
)}
297+
</Button>
298+
</div>
299+
</div>
300+
)}
301+
302+
{ticket.status === "closed" && feedbackSubmitted && (
153303
<div className="border-t p-6">
154304
<p className="text-muted-foreground text-sm">
155-
This ticket is closed. If you need further assistance, please
156-
create a new ticket.
305+
Thank you for your feedback! We appreciate your input and will use
306+
it to improve our service.
157307
</p>
158308
</div>
159309
)}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"use server";
2+
3+
type FeedbackSubmitResult = { success: true } | { error: string };
4+
type FeedbackStatusResult = { hasFeedback: boolean } | { error: string };
5+
6+
export async function submitSupportFeedback({
7+
rating,
8+
feedback,
9+
ticketId,
10+
}: {
11+
rating: number;
12+
feedback?: string;
13+
ticketId: string;
14+
}): Promise<FeedbackSubmitResult> {
15+
try {
16+
// Fail fast on missing configuration
17+
const siwaUrl =
18+
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
19+
20+
if (!siwaUrl) {
21+
throw new Error("SIWA URL not configured");
22+
}
23+
24+
const apiKey = process.env.SERVICE_AUTH_KEY_SIWA;
25+
26+
if (!apiKey) {
27+
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
28+
}
29+
30+
// Basic input validation/normalization
31+
if (!ticketId?.trim()) {
32+
return { error: "ticketId is required." };
33+
}
34+
35+
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
36+
return { error: "Rating must be an integer between 1 and 5." };
37+
}
38+
39+
const normalizedFeedback = (feedback ?? "")
40+
.toString()
41+
.trim()
42+
.slice(0, 1000); // hard cap length
43+
44+
const payload = {
45+
rating: rating.toString(),
46+
feedback: normalizedFeedback,
47+
ticket_id: ticketId,
48+
};
49+
50+
const ac = new AbortController();
51+
const t = setTimeout(() => ac.abort(), 10_000);
52+
const response = await fetch(`${siwaUrl}/v1/csat/saveCSATFeedback`, {
53+
method: "POST",
54+
cache: "no-store",
55+
headers: {
56+
"Content-Type": "application/json",
57+
"x-service-api-key": apiKey,
58+
},
59+
body: JSON.stringify(payload),
60+
signal: ac.signal,
61+
}).finally(() => clearTimeout(t));
62+
63+
if (!response.ok) {
64+
const errorText = await response.text();
65+
return { error: `API Server error: ${response.status} - ${errorText}` };
66+
}
67+
68+
return { success: true };
69+
} catch (error) {
70+
console.error("Feedback submission error:", error);
71+
return { error: "Internal server error" };
72+
}
73+
}
74+
75+
export async function checkFeedbackStatus(
76+
ticketId: string,
77+
): Promise<FeedbackStatusResult> {
78+
try {
79+
// Basic input validation
80+
if (!ticketId?.trim()) {
81+
return { error: "ticketId is required." };
82+
}
83+
84+
// Fail fast on missing configuration
85+
const siwaUrl =
86+
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
87+
88+
if (!siwaUrl) {
89+
throw new Error("SIWA URL not configured");
90+
}
91+
92+
const apiKey = process.env.SERVICE_AUTH_KEY_SIWA;
93+
94+
if (!apiKey) {
95+
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
96+
}
97+
98+
const fullUrl = `${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent(ticketId)}`;
99+
100+
const ac = new AbortController();
101+
const t = setTimeout(() => ac.abort(), 10_000);
102+
const response = await fetch(fullUrl, {
103+
method: "GET",
104+
cache: "no-store",
105+
headers: {
106+
"Content-Type": "application/json",
107+
"x-service-api-key": apiKey,
108+
},
109+
signal: ac.signal,
110+
}).finally(() => clearTimeout(t));
111+
112+
if (!response.ok) {
113+
const errorText = await response.text();
114+
return { error: `API Server error: ${response.status} - ${errorText}` };
115+
}
116+
117+
const data = await response.json();
118+
return { hasFeedback: data.has_feedback };
119+
} catch (error) {
120+
console.error("Feedback status check error:", error);
121+
return { error: "Internal server error" };
122+
}
123+
}

0 commit comments

Comments
 (0)