Files
spsays-training/app/(app)/settings/feedbacks/page.tsx
T
gosangamandClaude Opus 5.5 52cd48b12e Feedback form: load categories from the BFF
The form offers the admin-managed categories, falling back to the original
four if they can't be loaded. "My feedback" shows the server's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:47:09 +05:30

219 lines
6.3 KiB
TypeScript

"use client";
/**
* /settings/feedbacks — the signed-in user's own feedback history.
*
* Lists every submission they've made (newest first) with its current
* status — Submitted → Acknowledged → Resolved — and, when the team has
* written back, the response inline. Read-only: submitting new feedback
* still happens on /feedback.
*
* Anonymous visitors are prompted to sign in and bounced home if they
* dismiss, mirroring /settings/profile — the underlying endpoint is
* auth-only.
*/
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CheckCircle2, Clock, Eye, MessageSquarePlus } from "lucide-react";
import { feedback as feedbackAPI, SocialError, type Feedback } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { SettingsShell } from "@/components/settings/settings-ui";
import { cn } from "@/lib/utils";
const CATEGORY_LABEL: Record<string, string> = {
bug: "Bug report",
feature: "Feature request",
general: "General feedback",
other: "Other",
};
type Status = "resolved" | "acknowledged" | "open";
function statusOf(f: Feedback): Status {
if (f.resolved_at) return "resolved";
if (f.acknowledged_at) return "acknowledged";
return "open";
}
const STATUS_META: Record<
Status,
{ label: string; icon: React.ReactNode; className: string }
> = {
resolved: {
label: "Resolved",
icon: <CheckCircle2 className="size-3.5" />,
className:
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
},
acknowledged: {
label: "Acknowledged",
icon: <Eye className="size-3.5" />,
className: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
open: {
label: "Submitted",
icon: <Clock className="size-3.5" />,
className: "bg-muted text-muted-foreground",
},
};
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return "";
}
}
export default function FeedbacksPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [items, setItems] = useState<Feedback[] | null>(null);
const [error, setError] = useState<string | null>(null);
// Same auth gate as /settings/profile: prompt anonymous visitors and
// bounce them home if they dismiss.
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
const load = useCallback(async () => {
setError(null);
try {
setItems(await feedbackAPI.mine());
} catch (err) {
setError(
err instanceof SocialError
? err.message
: "Could not load your feedback.",
);
}
}, []);
useEffect(() => {
if (loading || !user) return;
void load();
}, [loading, user, load]);
return (
<SettingsShell
backHref="/settings"
backLabel="Settings"
title="My feedback"
description="Everything you've sent us, with its status and our response."
>
{loading || !user ? (
<p className="text-sm text-muted-foreground">
{loading ? "Loading…" : "Sign in to see your feedback."}
</p>
) : error ? (
<div className="space-y-3">
<p className="text-sm text-destructive">{error}</p>
<button
type="button"
onClick={() => void load()}
className="text-sm font-medium text-primary hover:underline"
>
Try again
</button>
</div>
) : items === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? (
<EmptyState />
) : (
<ul className="space-y-3">
{items.map((f) => (
<FeedbackCard key={f.id} feedback={f} />
))}
</ul>
)}
</SettingsShell>
);
}
function FeedbackCard({ feedback: f }: { feedback: Feedback }) {
const status = statusOf(f);
const meta = STATUS_META[status];
// The server's label tracks renames in the admin; the map covers an older
// BFF that doesn't send one yet.
const category = f.category_label || CATEGORY_LABEL[f.category] || f.category;
return (
<li className="rounded-xl border border-border bg-card p-4 sm:p-5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium",
meta.className,
)}
>
{meta.icon}
{meta.label}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">
{category}
</span>
<span className="text-muted-foreground">
{formatDate(f.created_at)}
</span>
</div>
<p className="mt-3 whitespace-pre-wrap text-sm text-foreground">
{f.message}
</p>
{f.reply_message ? (
<div className="mt-4 rounded-lg border-l-2 border-primary/40 bg-primary/5 px-3.5 py-3">
<p className="text-xs font-semibold text-primary">
Response from the team
{f.replied_at ? (
<span className="font-normal text-muted-foreground">
{" · "}
{formatDate(f.replied_at)}
</span>
) : null}
</p>
<p className="mt-1.5 whitespace-pre-wrap text-sm text-foreground">
{f.reply_message}
</p>
</div>
) : null}
</li>
);
}
function EmptyState() {
return (
<div className="py-14 text-center">
<MessageSquarePlus className="mx-auto size-8 text-muted-foreground/50" />
<p className="mt-3 text-sm text-muted-foreground">
You haven&rsquo;t sent any feedback yet.
</p>
<Link
href="/feedback"
className="mt-4 inline-flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Send feedback
</Link>
</div>
);
}