diff options
Diffstat (limited to 'apps/web/src/feedback')
| -rw-r--r-- | apps/web/src/feedback/components/feedback-popover.tsx | 242 | ||||
| -rw-r--r-- | apps/web/src/feedback/index.ts | 3 | ||||
| -rw-r--r-- | apps/web/src/feedback/queries.ts | 14 | ||||
| -rw-r--r-- | apps/web/src/feedback/types.ts | 11 |
4 files changed, 270 insertions, 0 deletions
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<FeedbackEntry[]>(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 ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button variant="outline" className="h-8"> + Send feedback + </Button> + </PopoverTrigger> + <PopoverContent align="end" className="w-80 p-0"> + <FeedbackPopoverContent onClose={() => setOpen(false)} /> + </PopoverContent> + </Popover> + ); +} + +type View = "compose" | "history"; + +function FeedbackPopoverContent({ onClose }: { onClose: () => void }) { + const { entries, isSubmitting, submit } = useFeedback(); + const [view, setView] = useState<View>("compose"); + + const form = useForm<FeedbackFormValues>({ + defaultValues: { message: "" }, + }); + + async function handleSubmit(values: FeedbackFormValues) { + await submit({ + values, + onSuccess: () => { + form.reset({ message: "" }); + clearFormDraft({ key: PERSIST_KEY }); + onClose(); + }, + }); + } + + if (view === "history") { + return ( + <div className="flex flex-col"> + <div + className="max-h-72 overflow-y-auto divide-y" + style={{ + maskImage: + "linear-gradient(to bottom, black 80%, transparent 100%)", + }} + > + {entries.map((entry) => ( + <FeedbackEntryItem key={entry.id} entry={entry} /> + ))} + </div> + <div className="border-t px-3 py-2"> + <button + type="button" + onClick={() => setView("compose")} + className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors" + > + ← Back + </button> + </div> + </div> + ); + } + + return ( + <div className="flex flex-col"> + <Form persistKey={PERSIST_KEY} {...form}> + <form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col"> + <FormField + control={form.control} + name="message" + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + placeholder="Thoughts, bugs, ideas..." + className="min-h-[7rem] text-sm p-3 bg-background shadow-none border-none! resize-none" + {...field} + /> + </FormControl> + </FormItem> + )} + /> + <div className="flex items-center justify-between border-t px-3 py-2"> + {entries.length > 0 ? ( + <button + type="button" + onClick={() => setView("history")} + className="flex items-center gap-1.5 text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors" + > + <ClockIcon className="size-3" /> + {entries.length} + </button> + ) : ( + <span /> + )} + <div className="flex gap-2"> + {!form.watch("message").trim() && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={onClose} + > + Cancel + </Button> + )} + <Button + type="submit" + size="sm" + disabled={isSubmitting || !form.watch("message").trim()} + > + {isSubmitting ? <Spinner /> : "Send"} + </Button> + </div> + </div> + </form> + </Form> + </div> + ); +} + +function relativeDate(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) { + return ( + <div className="px-3 py-2.5"> + <p className="text-sm text-muted-foreground leading-snug whitespace-pre-wrap break-words"> + {entry.message} + </p> + <span className="mt-1 block text-[11px] text-muted-foreground/50"> + {relativeDate(entry.createdAt)} + </span> + </div> + ); +} diff --git a/apps/web/src/feedback/index.ts b/apps/web/src/feedback/index.ts new file mode 100644 index 00000000..dfd0743c --- /dev/null +++ b/apps/web/src/feedback/index.ts @@ -0,0 +1,3 @@ +export { submitFeedback } from "./queries"; +export { MAX_MESSAGE_LENGTH } from "./types"; +export type { FeedbackEntry, SubmitFeedbackInput } from "./types"; diff --git a/apps/web/src/feedback/queries.ts b/apps/web/src/feedback/queries.ts new file mode 100644 index 00000000..121c6fe9 --- /dev/null +++ b/apps/web/src/feedback/queries.ts @@ -0,0 +1,14 @@ +import { db, feedback } from "@/db"; +import { generateUUID } from "@/utils/id"; +import type { FeedbackEntry, SubmitFeedbackInput } from "./types"; + +export async function submitFeedback({ + message, +}: SubmitFeedbackInput): Promise<FeedbackEntry> { + const id = generateUUID(); + const now = new Date(); + + await db.insert(feedback).values({ id, message, createdAt: now }); + + return { id, message, createdAt: now.toISOString() }; +} diff --git a/apps/web/src/feedback/types.ts b/apps/web/src/feedback/types.ts new file mode 100644 index 00000000..358b1354 --- /dev/null +++ b/apps/web/src/feedback/types.ts @@ -0,0 +1,11 @@ +export const MAX_MESSAGE_LENGTH = 5000; + +export interface FeedbackEntry { + id: string; + message: string; + createdAt: string; +} + +export interface SubmitFeedbackInput { + message: string; +} |
