diff --git a/apps/web/src/app/api/feedback/route.ts b/apps/web/src/app/api/feedback/route.ts new file mode 100644 index 00000000..b13ec2bf --- /dev/null +++ b/apps/web/src/app/api/feedback/route.ts @@ -0,0 +1,31 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback"; + +const submitSchema = z.object({ + message: z + .string() + .min(1, "Message is required") + .max(MAX_MESSAGE_LENGTH, "Message too long"), +}); + +export async function POST(request: NextRequest) { + const { limited } = await checkRateLimit({ request }); + if (limited) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const body = await request.json(); + const result = submitSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { error: "Invalid input", details: result.error.flatten().fieldErrors }, + { status: 400 }, + ); + } + + const entry = await submitFeedback(result.data); + return NextResponse.json({ entry }, { status: 201 }); +} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index ddfcaad4..44e8af68 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -15,6 +15,7 @@ import { DeleteProjectDialog } from "./dialogs/delete-project-dialog"; import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { ExportButton } from "./export-button"; +import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover"; import { ThemeToggle } from "../theme-toggle"; import { DEFAULT_LOGO_URL } from "@/lib/site/brand"; import { SOCIAL_LINKS } from "@/lib/site/social"; @@ -34,6 +35,7 @@ export function EditorHeader() { diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts index 88d71191..8f8be248 100644 --- a/apps/web/src/lib/db/schema.ts +++ b/apps/web/src/lib/db/schema.ts @@ -48,6 +48,14 @@ export const accounts = pgTable("accounts", { updatedAt: timestamp("updated_at").notNull(), }).enableRLS(); +export const feedback = pgTable("feedback", { + id: text("id").primaryKey(), + message: text("message").notNull(), + createdAt: timestamp("created_at") + .$defaultFn(() => new Date()) + .notNull(), +}); + export const verifications = pgTable("verifications", { id: text("id").primaryKey(), identifier: text("identifier").notNull(), diff --git a/apps/web/src/lib/feedback/components/feedback-popover.tsx b/apps/web/src/lib/feedback/components/feedback-popover.tsx new file mode 100644 index 00000000..9bdc48f4 --- /dev/null +++ b/apps/web/src/lib/feedback/components/feedback-popover.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +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)} /> + + + ); +} + +function FeedbackPopoverContent({ onClose }: { onClose: () => void }) { + const { entries, isSubmitting, submit } = useFeedback(); + + const form = useForm({ + defaultValues: { message: "" }, + }); + + async function handleSubmit(values: FeedbackFormValues) { + await submit({ + values, + onSuccess: () => { + form.reset({ message: "" }); + clearFormDraft({ key: PERSIST_KEY }); + onClose(); + }, + }); + } + + return ( +
+
+ + ( + + +