- New /support route with Discord join button and contact form - Contact form sends via Resend to support@brackt.com (email stays hidden from users) - Spam protection: honeypot field + Cloudflare Turnstile verification - Server-side input length limits (subject: 200, message: 5000 chars) - Added Support nav link to navbar (desktop + mobile) Fixes #233 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bde1e6e5f0
commit
29ea93ad6d
6 changed files with 276 additions and 5 deletions
|
|
@ -7,3 +7,6 @@ CONTAINER_REGISTRY=""
|
|||
DEV_ADMIN_CLERK_ID=""
|
||||
SENTRY_AUTH_TOKEN=""
|
||||
SQUIGGLE_CONTACT_EMAIL=""
|
||||
RESEND_API_KEY=""
|
||||
TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
|
|
@ -50,6 +50,11 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
<Link to="/rules">Rules</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link to="/support">Support</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
{isAdmin && (
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
|
|
@ -122,6 +127,13 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
>
|
||||
Rules
|
||||
</Link>
|
||||
<Link
|
||||
to="/support"
|
||||
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/admin"
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export default [
|
|||
route("user-profile", "routes/user-profile.tsx"),
|
||||
route("how-to-play", "routes/how-to-play.tsx"),
|
||||
route("rules", "routes/rules.tsx"),
|
||||
route("support", "routes/support.tsx"),
|
||||
route("test-socket", "routes/test-socket.tsx"),
|
||||
|
||||
// Admin routes
|
||||
|
|
|
|||
215
app/routes/support.tsx
Normal file
215
app/routes/support.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { data, useActionData, useLoaderData } from "react-router";
|
||||
import { Resend } from "resend";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { MessageCircle, Mail, CheckCircle } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import type { Route } from "./+types/support";
|
||||
|
||||
const SUBJECT_MAX = 200;
|
||||
const MESSAGE_MAX = 5000;
|
||||
|
||||
export function loader() {
|
||||
return { turnstileSiteKey: process.env.TURNSTILE_SITE_KEY ?? "" };
|
||||
}
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: "Support - Brackt" },
|
||||
{ name: "description", content: "Get help with Brackt" },
|
||||
];
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Honeypot check — silently succeed to not tip off bots
|
||||
if (formData.get("website")) {
|
||||
return data({ success: true as const });
|
||||
}
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const email = String(formData.get("email") ?? "").trim();
|
||||
const subject = String(formData.get("subject") ?? "").trim();
|
||||
const message = String(formData.get("message") ?? "").trim();
|
||||
const turnstileToken = String(formData.get("cf-turnstile-response") ?? "");
|
||||
|
||||
// Basic validation
|
||||
if (!name || !email || !subject || !message) {
|
||||
return data({ error: "All fields are required." });
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return data({ error: "Please enter a valid email address." });
|
||||
}
|
||||
if (subject.length > SUBJECT_MAX) {
|
||||
return data({ error: `Subject must be ${SUBJECT_MAX} characters or fewer.` });
|
||||
}
|
||||
if (message.length > MESSAGE_MAX) {
|
||||
return data({ error: `Message must be ${MESSAGE_MAX} characters or fewer.` });
|
||||
}
|
||||
|
||||
// Turnstile verification
|
||||
const turnstileSecret = process.env.TURNSTILE_SECRET_KEY;
|
||||
if (turnstileSecret) {
|
||||
if (!turnstileToken) {
|
||||
return data({ error: "Please complete the verification." });
|
||||
}
|
||||
const verifyResponse = await fetch(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
secret: turnstileSecret,
|
||||
response: turnstileToken,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const verifyResult = (await verifyResponse.json()) as { success: boolean };
|
||||
if (!verifyResult.success) {
|
||||
return data({ error: "Verification failed. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
// Send email via Resend
|
||||
const resendKey = process.env.RESEND_API_KEY;
|
||||
if (!resendKey) {
|
||||
return data({ error: "Server configuration error. Please try again later." });
|
||||
}
|
||||
|
||||
const resend = new Resend(resendKey);
|
||||
const { error: sendError } = await resend.emails.send({
|
||||
from: "Brackt Support Form <noreply@brackt.com>",
|
||||
to: "support@brackt.com",
|
||||
replyTo: email,
|
||||
subject: `[Support] ${subject}`,
|
||||
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
|
||||
});
|
||||
|
||||
if (sendError) {
|
||||
return data({ error: "Failed to send your message. Please try again." });
|
||||
}
|
||||
|
||||
return data({ success: true as const });
|
||||
}
|
||||
|
||||
export default function Support() {
|
||||
const { turnstileSiteKey } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const success = actionData && "success" in actionData;
|
||||
const error = actionData && "error" in actionData ? actionData.error : null;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 className="text-4xl font-bold mb-2">Support</h1>
|
||||
<p className="text-lg text-muted-foreground mb-10">
|
||||
Need help? Join our Discord community for the fastest response, or send
|
||||
us a message directly.
|
||||
</p>
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Discord */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<MessageCircle className="h-6 w-6 text-indigo-400" />
|
||||
<h2 className="text-2xl font-semibold">Discord Community</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
The fastest way to get help is to join our Discord server. You can
|
||||
ask questions, report bugs, and chat with other Brackt players.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://discord.gg/KyCHH4zZ5j"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MessageCircle className="mr-2 h-4 w-4" />
|
||||
Join our Discord
|
||||
</a>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* Contact Form */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Mail className="h-6 w-6 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-semibold">Send a Message</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Prefer email? Fill out the form below and we'll get back to you.
|
||||
</p>
|
||||
|
||||
{success ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-green-500/30 bg-green-500/10 p-4 text-green-400">
|
||||
<CheckCircle className="h-5 w-5 shrink-0" />
|
||||
<p>Message sent! We'll get back to you as soon as we can.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form method="post" className="space-y-5 max-w-xl">
|
||||
{/* Honeypot — visually and positionally hidden; not using display:none so bots can't trivially detect it */}
|
||||
<input
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
autoComplete="off"
|
||||
style={{ position: "absolute", opacity: 0, height: 0, width: 0, pointerEvents: "none" }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" placeholder="Your name" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
placeholder="What's this about?"
|
||||
maxLength={SUBJECT_MAX}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder="Describe your issue or question..."
|
||||
rows={5}
|
||||
maxLength={MESSAGE_MAX}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile siteKey={turnstileSiteKey} />
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit">Send Message</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@marsidev/react-turnstile": "^1.4.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.7.1",
|
||||
"recharts": "^3.4.1",
|
||||
"resend": "^6.9.4",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -2483,6 +2485,16 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@marsidev/react-turnstile": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.4.2.tgz",
|
||||
"integrity": "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mjackson/node-fetch-server": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
|
||||
|
|
@ -13048,6 +13060,12 @@
|
|||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
|
@ -13750,6 +13768,27 @@
|
|||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "6.9.4",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz",
|
||||
"integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postal-mime": "2.7.3",
|
||||
"svix": "1.86.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-email/render": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-email/render": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
|
|
@ -14950,13 +14989,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/svix": {
|
||||
"version": "1.77.0",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.77.0.tgz",
|
||||
"integrity": "sha512-rqyvcFHMq1eGIjYwZEEsW5MkeLH4FRr23TuSsLLhH+/wilK4sjdJSYmALTke3kyMqab7lqWTc9jyKFw6o0/oKg==",
|
||||
"version": "1.86.0",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
|
||||
"integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0",
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@marsidev/react-turnstile": "^1.4.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
|
|
@ -65,6 +66,7 @@
|
|||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.7.1",
|
||||
"recharts": "^3.4.1",
|
||||
"resend": "^6.9.4",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue