217 lines
6.2 KiB
TypeScript
217 lines
6.2 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];
|
|
const category = 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’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>
|
|
);
|
|
}
|