From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../src/feedback/components/feedback-popover.tsx | 242 +++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 apps/web/src/feedback/components/feedback-popover.tsx (limited to 'apps/web/src/feedback/components') diff --git a/apps/web/src/feedback/components/feedback-popover.tsx b/apps/web/src/feedback/components/feedback-popover.tsx new file mode 100644 index 00000000..f94b7253 --- /dev/null +++ b/apps/web/src/feedback/components/feedback-popover.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { ClockIcon } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Spinner } from "@/components/ui/spinner"; +import { + Form, + FormField, + FormItem, + FormControl, + clearFormDraft, +} from "@/components/ui/form"; +import type { FeedbackEntry } from "../types"; + +const PERSIST_KEY = "feedback-draft"; +const HISTORY_KEY = "feedback-history"; +const MAX_HISTORY = 20; + +interface FeedbackFormValues { + message: string; +} + +function readHistory(): FeedbackEntry[] { + try { + const stored = localStorage.getItem(HISTORY_KEY); + return stored ? (JSON.parse(stored) as FeedbackEntry[]) : []; + } catch { + return []; + } +} + +function writeHistory({ entries }: { entries: FeedbackEntry[] }): void { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(entries)); + } catch { + // localStorage may be full or unavailable + } +} + +function useFeedback() { + const [entries, setEntries] = useState(readHistory); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function submit({ + values, + onSuccess, + }: { + values: FeedbackFormValues; + onSuccess: () => void; + }) { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + const res = await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.error ?? "Failed to submit"); + } + + const { entry } = await res.json(); + const next = [entry, ...entries].slice(0, MAX_HISTORY); + setEntries(next); + writeHistory({ entries: next }); + onSuccess(); + toast.success("Feedback sent"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to send feedback", + ); + } finally { + setIsSubmitting(false); + } + } + + return { entries, isSubmitting, submit }; +} + +export function FeedbackPopover() { + const [open, setOpen] = useState(false); + + return ( + + + + + + setOpen(false)} /> + + + ); +} + +type View = "compose" | "history"; + +function FeedbackPopoverContent({ onClose }: { onClose: () => void }) { + const { entries, isSubmitting, submit } = useFeedback(); + const [view, setView] = useState("compose"); + + const form = useForm({ + defaultValues: { message: "" }, + }); + + async function handleSubmit(values: FeedbackFormValues) { + await submit({ + values, + onSuccess: () => { + form.reset({ message: "" }); + clearFormDraft({ key: PERSIST_KEY }); + onClose(); + }, + }); + } + + if (view === "history") { + return ( +
+
+ {entries.map((entry) => ( + + ))} +
+
+ +
+
+ ); + } + + return ( +
+
+ + ( + + +