brackt/app/routes/support.tsx
Chris Parsons 29ea93ad6d
Add support page with Discord link and contact form, fixes #233 (#234)
- 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>
2026-03-26 17:17:49 -07:00

215 lines
7.3 KiB
TypeScript

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>
);
}