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,
|
BookOpenText,
|
||||||
DownloadCloud,
|
DownloadCloud,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
MessageSquareText,
|
||||||
Scale,
|
Scale,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserRound,
|
UserRound,
|
||||||
@@ -66,6 +67,12 @@ export default function SettingsPage() {
|
|||||||
label="Account & security"
|
label="Account & security"
|
||||||
description="Connected accounts, email, and password."
|
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
|
<NavRow
|
||||||
href="/settings/legal"
|
href="/settings/legal"
|
||||||
icon={<Scale className="size-5" />}
|
icon={<Scale className="size-5" />}
|
||||||
|
|||||||
@@ -202,6 +202,13 @@ export type Feedback = {
|
|||||||
message: string;
|
message: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
created_at: 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 ───────────────────────────────────────────────────────────────
|
// ─── 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 ──────────────────────────────────────────────────────
|
// ─── Public profile ──────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user