Add contact page and form

This commit is contained in:
Jared Schoeny 2025-11-06 01:34:04 -10:00
parent 60ff8a98a5
commit 3bbbc40253
5 changed files with 1721 additions and 0 deletions

1405
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@
"embla-carousel-react": "8.6.0",
"minio": "^8.0.6",
"next": "15.5.4",
"nodemailer": "^7.0.10",
"react": "19.1.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.1.0",
@ -37,6 +38,7 @@
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/nodemailer": "^7.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"patch-package": "^8.0.0",

112
src/app/contact/actions.ts Normal file
View File

@ -0,0 +1,112 @@
"use server";
import nodemailer from "nodemailer";
export interface ContactActionState {
error: string | null;
success?: string | null;
}
type Topic =
| "general"
| "bug"
| "account"
| "creator"
| "security"
| "other";
const topicLabels: Record<Topic, string> = {
general: "General question",
bug: "Bug report",
account: "Account issue",
creator: "Creator support",
security: "Security disclosure",
other: "Other",
};
function generateTicketId(): string {
const rand = Math.random().toString(36).slice(2, 8).toUpperCase();
return `HDX-${rand}`;
}
export async function sendContact(prev: ContactActionState, formData: FormData): Promise<ContactActionState> {
try {
const topic = (formData.get("topic") as string | null)?.toLowerCase() as Topic | null;
const name = (formData.get("name") as string | null) || "";
const email = (formData.get("email") as string | null) || "";
const contextUrl = (formData.get("contextUrl") as string | null) || "";
const message = (formData.get("message") as string | null) || "";
if (!topic || !(topic in topicLabels)) {
return { error: "Please select a valid topic." };
}
if (!email) {
return { error: "Email is required." };
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST!,
port: Number(process.env.SMTP_PORT!),
requireTLS: true,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
});
const ticketId = generateTicketId();
const noreply = process.env.EMAIL_NOREPLY!;
if (!message) {
return { error: "Message is required." };
}
const subject = `[#${ticketId}] ${topicLabels[topic]}${name ? ` from ${name}` : ""}`;
const body = [
`Topic: ${topicLabels[topic]}`,
name ? `Name: ${name}` : undefined,
`Email: ${email}`,
contextUrl ? `Related URL: ${contextUrl}` : undefined,
"",
"Message:",
message,
]
.filter(Boolean)
.join("\n");
await transporter.sendMail({
from: `Hackdex <${noreply}>`,
to: process.env.EMAIL_ADMIN!,
replyTo: email,
subject,
text: body,
});
const confirmationMessage = [
`We received your message (ticket #${ticketId}).`,
"We'll review and follow up if we need more information.",
"\n\nSummary:",
`Topic: ${topicLabels[topic]}`,
name ? `Name: ${name}` : undefined,
`Email: ${email}`,
contextUrl ? `Related URL: ${contextUrl}` : undefined,
]
.filter(Boolean)
.join("\n");
await transporter.sendMail({
from: `Hackdex <${noreply}>`,
to: email,
subject: `[#${ticketId}] Support request confirmation`,
text: confirmationMessage,
});
return { error: null, success: `Your message was sent. Ticket #${ticketId}.` };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to send message.";
return { error: message };
}
}

26
src/app/contact/page.tsx Normal file
View File

@ -0,0 +1,26 @@
import Link from "next/link";
import ContactForm from "@/components/Contact/ContactForm";
export default function ContactPage() {
const copyrightEmail = process.env.EMAIL_COPYRIGHT!;
const contactEmail = process.env.EMAIL_CONTACT!;
return (
<div className="mx-auto my-auto max-w-2xl w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Contact Hackdex</h1>
<p className="mt-1 text-sm text-foreground/70">
Have a question, feedback, or need help? Use this form to reach out to us.
</p>
<p className="mt-1 text-sm text-foreground/60">
For intellectual property concerns (DMCA), please see our <Link className="text-[var(--accent)] hover:underline" href="/terms">Terms of Service</Link>.
</p>
<div className="mt-6">
<ContactForm />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,176 @@
"use client";
import React, { useActionState } from "react";
import { useSearchParams } from "next/navigation";
import { validateEmail } from "@/utils/auth";
import { sendContact, type ContactActionState } from "@/app/contact/actions";
type Topic =
| "general"
| "bug"
| "account"
| "creator"
| "security"
| "other";
const topicLabels: Record<Topic, string> = {
general: "General question",
bug: "Bug report",
account: "Account issue",
creator: "Creator support",
security: "Security disclosure",
other: "Other",
};
export default function ContactForm() {
const searchParams = useSearchParams();
const topicFromParams = (searchParams.get("topic") || "general").toLowerCase() as Topic;
const [topic, setTopic] = React.useState<Topic>(
(Object.keys(topicLabels) as Topic[]).includes(topicFromParams) ? topicFromParams : "general"
);
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState("");
const [emailError, setEmailError] = React.useState<string | null>(null);
const [message, setMessage] = React.useState("");
const [contextUrl, setContextUrl] = React.useState("");
React.useEffect(() => {
const { error } = validateEmail(email);
setEmailError(error);
}, [email]);
const isValid = React.useMemo(() => {
return !emailError && !!email && !!message;
}, [emailError, email, message]);
const [state, formAction, isPending] = useActionState<ContactActionState, FormData>(sendContact, { error: null, success: null });
React.useEffect(() => {
if (state.success) {
// Reset form on success
setName("");
setEmail("");
setMessage("");
setContextUrl("");
}
}, [state.success]);
return (
<form className="grid gap-5 group">
{(state.error && !isPending) && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{state.error}
</div>
)}
{(state.success && !isPending) && (
<div className="rounded-md bg-green-500/10 ring-1 ring-green-600/40 px-3 py-2 text-sm text-green-300">
{state.success}
</div>
)}
<div className="grid gap-2">
<label htmlFor="topic" className="text-sm text-foreground/80">Topic</label>
<select
id="topic"
name="topic"
value={topic}
onChange={(e) => setTopic(e.target.value as Topic)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
{(Object.keys(topicLabels) as Topic[]).map((key) => (
<option key={key} value={key}>{topicLabels[key]}</option>
))}
</select>
<span className="text-xs text-foreground/60">
Choose the most relevant topic so we can help you better.
</span>
</div>
<div className="grid gap-2 md:grid-cols-2 md:gap-4">
<div className="grid gap-2">
<label htmlFor="name" className="text-sm text-foreground/80">Name (optional)</label>
<input
id="name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
autoComplete="name"
/>
</div>
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
email && emailError ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "ring-[var(--border)]"
}`}
required
autoComplete="email"
/>
{email && emailError && (
<span className="text-xs text-red-500/70">{emailError}</span>
)}
</div>
</div>
{(topic === "bug" || topic === "creator" || topic === "account") && (
<div className="grid gap-2">
<label htmlFor="contextUrl" className="text-sm text-foreground/80">Related URL (optional)</label>
<input
id="contextUrl"
name="contextUrl"
type="url"
value={contextUrl}
onChange={(e) => setContextUrl(e.target.value)}
placeholder="https://hackdex.app/..."
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
inputMode="url"
/>
<span className="text-xs text-foreground/60">Linking the exact page helps us investigate faster.</span>
</div>
)}
<div className="grid gap-2">
<label htmlFor="message" className="text-sm text-foreground/80">Message</label>
<textarea
id="message"
name="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={
topic === "bug"
? "What happened? What did you expect? Any steps to reproduce?"
: topic === "security"
? "Please provide enough detail to help us triage. Avoid sharing sensitive data."
: "How can we help?"
}
className="min-h-[8rem] rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
required
/>
</div>
<div className="flex flex-col items-center gap-3">
<button
type="submit"
formAction={formAction}
disabled={!isValid || isPending}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
aria-label="Send message"
title="Send message"
>
<span>{isPending ? "Sending…" : "Send message"}</span>
</button>
</div>
</form>
);
}