Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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");
Expand Down Expand Up @@ -149,11 +222,88 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
)}
</div>

{ticket.status === "closed" && (
{ticket.status === "closed" && isLoading && (
<div className="border-t p-6">
<div className="flex items-center gap-2">
<Spinner className="size-4" />
<span className="text-muted-foreground text-sm">
Checking feedback status...
</span>
</div>
</div>
)}

{ticket.status === "closed" && !isLoading && !feedbackSubmitted && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
This ticket is closed. Give us a quick rating to let us know how
we did!
</p>
{hasError && (
<p className="text-destructive text-xs mt-2">
Couldn't verify prior feedback right now — you can still submit
a rating.
</p>
)}

<div className="flex gap-2 mb-6 mt-4">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={`star-${starValue}`}
type="button"
onClick={() => handleStarClick(starValue - 1)}
className="transition-colors"
aria-label={`Rate ${starValue} out of 5 stars`}
>
<StarIcon
size={32}
className={cn(
"transition-colors",
starValue <= rating
? "text-pink-500 fill-current stroke-current"
: "text-muted-foreground fill-current stroke-current",
"hover:text-pink-500",
)}
strokeWidth={starValue <= rating ? 2 : 1}
/>
</button>
))}
</div>

<div className="relative">
<AutoResizeTextarea
value={feedback}
onChange={(e) => 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"
/>
<Button
type="button"
onClick={handleSendFeedback}
disabled={submitFeedbackMutation.isPending || rating === 0}
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
variant="secondary"
size="sm"
>
{submitFeedbackMutation.isPending ? (
<>
<Spinner className="size-4 mr-2" />
Sending...
</>
) : (
"Send Feedback"
)}
</Button>
</div>
</div>
)}

{ticket.status === "closed" && feedbackSubmitted && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
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.
</p>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FeedbackSubmitResult> {
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<FeedbackStatusResult> {
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" };
}
}
Loading