mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Add contact page and form
This commit is contained in:
parent
60ff8a98a5
commit
3bbbc40253
1405
package-lock.json
generated
1405
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -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
112
src/app/contact/actions.ts
Normal 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
26
src/app/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
176
src/components/Contact/ContactForm.tsx
Normal file
176
src/components/Contact/ContactForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user