feat: auth user feedbacks
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
BookOpenText,
|
||||
DownloadCloud,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
@@ -66,6 +67,12 @@ export default function SettingsPage() {
|
||||
label="Account & security"
|
||||
description="Connected accounts, email, and password."
|
||||
/>
|
||||
<NavRow
|
||||
href="/settings/feedbacks"
|
||||
icon={<MessageSquareText className="size-5" />}
|
||||
label="My feedback"
|
||||
description="Your submissions, their status, and our responses."
|
||||
/>
|
||||
<NavRow
|
||||
href="/settings/legal"
|
||||
icon={<Scale className="size-5" />}
|
||||
|
||||
@@ -202,6 +202,13 @@ export type Feedback = {
|
||||
message: string;
|
||||
contact_email?: string;
|
||||
created_at: string;
|
||||
/** Set when the team has seen the submission (one-way). */
|
||||
acknowledged_at?: string;
|
||||
/** Set when the submission has been addressed (one-way). */
|
||||
resolved_at?: string;
|
||||
/** The team's written reply, if any, and when it was sent. */
|
||||
reply_message?: string;
|
||||
replied_at?: string;
|
||||
};
|
||||
|
||||
// ─── Posts ───────────────────────────────────────────────────────────────
|
||||
@@ -559,6 +566,11 @@ export const feedback = {
|
||||
}),
|
||||
});
|
||||
},
|
||||
/** The signed-in user's own submissions, newest first — each with its
|
||||
* status (acknowledged / resolved) and any admin reply. Auth-only. */
|
||||
mine() {
|
||||
return call<Feedback[]>("/feedback/mine");
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Public profile ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user