From e14dafde62aaf90bb75ee5976f0b2e07cc2a9e35 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 28 Apr 2026 02:09:55 +0800 Subject: [PATCH] feat: add contact/report page with Discord webhook integration --- app/contact/layout.tsx | 11 ++ app/contact/page.tsx | 357 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 app/contact/layout.tsx create mode 100644 app/contact/page.tsx diff --git a/app/contact/layout.tsx b/app/contact/layout.tsx new file mode 100644 index 0000000..e4e0c4d --- /dev/null +++ b/app/contact/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next" + +export const metadata: Metadata = { + title: "Contact & Report", + description: "Report issues, suggest features, or send feedback to the DeckyVault team.", + robots: { index: false, follow: true }, +} + +export default function ContactLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..746829b --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,357 @@ +"use client" + +import { useState, useRef } from "react" +import { motion } from "motion/react" +import { + SendIcon, + BugIcon, + DatabaseIcon, + FlagIcon, + LightbulbIcon, + MessageSquareIcon, + AlertTriangleIcon, + CheckCircleIcon, + Loader2Icon, +} from "lucide-react" + +type Category = "bug" | "game_data" | "user_report" | "feature" | "feedback" | "database" + +interface CategoryOption { + id: Category + label: string + icon: React.ElementType + color: string +} + +const CATEGORIES: CategoryOption[] = [ + { id: "bug", label: "Bug Report", icon: BugIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" }, + { id: "game_data", label: "Game Data Issue", icon: AlertTriangleIcon, color: "text-yellow-400 border-yellow-500/20 bg-yellow-500/5 hover:bg-yellow-500/10" }, + { id: "user_report", label: "User Report", icon: FlagIcon, color: "text-blue-400 border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10" }, + { id: "feature", label: "Feature Request", icon: LightbulbIcon, color: "text-green-400 border-green-500/20 bg-green-500/5 hover:bg-green-500/10" }, + { id: "feedback", label: "General Feedback", icon: MessageSquareIcon, color: "text-text/60 border-border bg-text/3 hover:bg-text/6" }, + { id: "database", label: "Database Error", icon: DatabaseIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" }, +] + +export default function ContactPage() { + const [category, setCategory] = useState("") + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [subject, setSubject] = useState("") + const [message, setMessage] = useState("") + const [gameUrl, setGameUrl] = useState("") + const [honeypot, setHoneypot] = useState("") + const timestampRef = useRef("") + + const [submitting, setSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState(null) + const [fieldErrors, setFieldErrors] = useState>({}) + + // Set timestamp when user starts interacting + const startTimestamp = () => { + if (!timestampRef.current) { + timestampRef.current = Date.now().toString() + } + } + + const validate = (): boolean => { + const errors: Record = {} + + if (!category) errors.category = "Please select a category" + if (!subject.trim()) errors.subject = "Subject is required" + else if (subject.length > 200) errors.subject = "Subject must be 200 characters or less" + if (!message.trim()) errors.message = "Message is required" + else if (message.length > 2000) errors.message = "Message must be 2000 characters or less" + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Invalid email format" + if (category === "game_data" && gameUrl && !gameUrl.includes("/game/")) errors.gameUrl = "Please provide a valid DeckyVault game link" + + setFieldErrors(errors) + return Object.keys(errors).length === 0 + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + startTimestamp() + + if (!validate()) return + + setSubmitting(true) + setError(null) + + try { + const res = await fetch("/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + category, + name: name || undefined, + email: email || undefined, + subject: subject.trim(), + message: message.trim(), + gameUrl: category === "game_data" ? gameUrl || undefined : undefined, + honeypot, + _timestamp: timestampRef.current || undefined, + }), + }) + + const data = await res.json() + + if (!res.ok) { + if (res.status === 429) { + setError("You've sent too many messages. Please try again later.") + } else { + setError(data.error || "Something went wrong. Please try again.") + } + return + } + + setSubmitted(true) + } catch { + setError("Network error. Please check your connection and try again.") + } finally { + setSubmitting(false) + } + } + + if (submitted) { + return ( +
+ + +

Message Sent

+

+ Thank you for reaching out. We'll review your message as soon as possible. +

+ +
+
+ ) + } + + return ( +
+
+ {/* Header */} + +

Contact & Report

+

+ Report issues, suggest features, or send us feedback. +

+
+ + + {/* Honeypot */} + setHoneypot(e.target.value)} + tabIndex={-1} + autoComplete="off" + style={{ position: "absolute", opacity: 0, pointerEvents: "none" }} + aria-hidden="true" + /> + + {/* Category */} +
+ + Category * + +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ {fieldErrors.category && ( +

{fieldErrors.category}

+ )} +
+ + {/* Name & Email (optional) */} +
+
+ + { setName(e.target.value); startTimestamp() }} + placeholder="Your name" + className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors" + /> +
+
+ + { setEmail(e.target.value); startTimestamp() }} + placeholder="you@example.com" + className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors" + /> + {fieldErrors.email && ( +

{fieldErrors.email}

+ )} +
+
+ + {/* Subject */} +
+ + { + setSubject(e.target.value) + startTimestamp() + if (fieldErrors.subject) setFieldErrors((prev) => { const next = { ...prev }; delete next.subject; return next }) + }} + placeholder="Brief description of your issue" + maxLength={200} + className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors" + /> + {fieldErrors.subject && ( +

{fieldErrors.subject}

+ )} +
+ + {/* Game URL (conditional) */} + {category === "game_data" && ( + + + setGameUrl(e.target.value)} + placeholder="https://deckyvault.xyz/game/..." + className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors" + /> + {fieldErrors.gameUrl && ( +

{fieldErrors.gameUrl}

+ )} +
+ )} + + {/* Message */} +
+ +