nomadweb/frontend/src/components/FeedbackWidget.tsx
eric bdf0516197 Fix dating match gate and expand seed data for usable demos.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:18:25 -05:00

161 lines
4.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
const DRAFT_KEY = "nomadro-feedback-draft";
export default function FeedbackWidget() {
const { token } = useAuth();
const { toast } = useToast();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [type, setType] = useState("feature");
const [msg, setMsg] = useState("");
const [sending, setSending] = useState(false);
const [ready, setReady] = useState(false);
const types = [
{ key: "feature", label: `💡 ${t.feedback.catFeature}` },
{ key: "bug", label: `🐛 ${t.feedback.catBug}` },
{ key: "general", label: `❤️ ${t.feedback.catGeneral}` },
];
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => {
if (!open) return;
const draft = loadDraft<{ content?: string; category?: string }>(DRAFT_KEY);
if (draft) {
if (draft.content) setMsg(draft.content);
if (draft.category) setType(draft.category);
}
setReady(true);
}, [open]);
useEffect(() => {
if (!open || !ready) return;
const id = window.setTimeout(() => {
if (!msg.trim()) {
clearDraft(DRAFT_KEY);
return;
}
saveDraft(DRAFT_KEY, { content: msg, category: type });
}, 400);
return () => window.clearTimeout(id);
}, [msg, type, open, ready]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!msg.trim() || msg.trim().length < 5) {
toast(t.feedback.minLength, "info");
return;
}
setSending(true);
try {
await api.submitFeedback(msg.trim(), token || undefined, type);
try {
const key = "nomadro-feedback";
const prev = JSON.parse(localStorage.getItem(key) || "[]");
prev.push({ type, msg: msg.trim(), at: new Date().toISOString() });
localStorage.setItem(key, JSON.stringify(prev.slice(-50)));
} catch {
/* ignore */
}
clearDraft(DRAFT_KEY);
toast(t.feedback.ok, "success", {
href: "/community",
label: t.feedback.goCommunity,
});
setMsg("");
setOpen(false);
} catch {
toast(t.feedback.failRetry, "error");
} finally {
setSending(false);
}
};
return (
<>
<button
className="feedback-fab"
onClick={() => setOpen(true)}
aria-label={t.feedback.fabAria}
title={t.feedback.fabTitle}
>
💬
</button>
{open && (
<div
className="modal-overlay open"
role="dialog"
aria-modal="true"
aria-label={t.feedback.widgetTitle}
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
>
<div className="modal feedback-modal">
<button className="modal-close" onClick={() => setOpen(false)} aria-label={t.feedback.close}>
✕
</button>
<div className="feedback-body">
<span className="section-tag">{t.feedback.tag}</span>
<h2>{t.feedback.widgetTitle}</h2>
<p>{t.feedback.widgetSubtitle}</p>
<form onSubmit={(e) => void submit(e)}>
<div className="feedback-types">
{types.map((item) => (
<button
key={item.key}
type="button"
className={`filter-btn${type === item.key ? " active" : ""}`}
onClick={() => setType(item.key)}
>
{item.label}
</button>
))}
</div>
<textarea
className="feedback-textarea"
rows={4}
placeholder={t.feedback.placeholder}
value={msg}
onChange={(e) => setMsg(e.target.value)}
required
maxLength={500}
/>
<button
type="submit"
className="btn btn-primary"
disabled={sending || !msg.trim()}
style={{ width: "100%" }}
>
{sending ? t.feedback.submitting : t.feedback.sendBtn}
</button>
<p className="feedback-more-link">
<Link href="/feedback" onClick={() => setOpen(false)}>
{t.feedback.openFull}
</Link>
</p>
</form>
</div>
</div>
</div>
)}
</>
);
}