Introduces a shared email module that wraps all transactional emails in a cohesive dark-themed template matching the site's visual identity (Barlow font, #14171e background, #adf661 CTA buttons, logomark PNG header). - app/lib/email.server.ts: new module with sendEmail, wrapInEmailTemplate, emailButton, emailParagraph, and escapeHtml helpers; Resend instance is lazily initialized as a singleton - public/email-logo.png: 96×124px PNG of the logomark for email use (SVG gradients are not supported in Outlook) - auth.server.ts: password reset and email verification now use the branded template; errors are thrown so Better Auth can surface failures - support.tsx: internal notification uses sendEmail; adds a user-facing branded confirmation email (fire-and-forget so failures don't surface to the user); plain text fallback included - scripts/sync-prod-db.sh: remove first_name/last_name from sanitize query (columns were dropped in #399) - 13 unit tests covering all template helpers Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
231 lines
8.3 KiB
TypeScript
231 lines
8.3 KiB
TypeScript
import { data, useActionData, useLoaderData } from "react-router";
|
|
import { sendEmail, wrapInEmailTemplate, emailButton, emailParagraph, escapeHtml } from "~/lib/email.server";
|
|
import { logger } from "~/lib/logger";
|
|
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 internal notification to support@brackt.com
|
|
const { error: sendError } = await sendEmail({
|
|
from: "Brackt Support Form <noreply@brackt.com>",
|
|
to: "support@brackt.com",
|
|
replyTo: email,
|
|
subject: `[Support] ${subject}`,
|
|
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
|
|
html: wrapInEmailTemplate(
|
|
emailParagraph(`<strong>From:</strong> ${escapeHtml(name)} <${escapeHtml(email)}>`) +
|
|
emailParagraph(`<strong>Subject:</strong> ${escapeHtml(subject)}`) +
|
|
emailParagraph(escapeHtml(message).replace(/\n/g, "<br>")),
|
|
),
|
|
});
|
|
|
|
if (sendError) {
|
|
return data({ error: "Failed to send your message. Please try again." });
|
|
}
|
|
|
|
// Send confirmation to the user — fire and forget so a failure doesn't surface as a user error
|
|
const appUrl = process.env.APP_URL ?? "https://brackt.com";
|
|
sendEmail({
|
|
to: email,
|
|
subject: "We received your message — Brackt Support",
|
|
html: wrapInEmailTemplate(
|
|
emailParagraph(`Hi ${escapeHtml(name)}, thanks for reaching out!`) +
|
|
emailParagraph("We've received your message and will get back to you as soon as we can.") +
|
|
emailButton(appUrl, "Back to Brackt"),
|
|
"We received your Brackt support request."
|
|
),
|
|
text: `Hi ${name}, thanks for reaching out!\n\nWe've received your message and will get back to you as soon as we can.\n\nBrackt — ${appUrl}`,
|
|
}).then(({ error }) => {
|
|
if (error) logger.warn("Support confirmation email failed", { error, to: email });
|
|
});
|
|
|
|
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>
|
|
);
|
|
}
|