151 lines
5.6 KiB
TypeScript
151 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { site } from "@/lib/site";
|
||
|
||
type Status = { state: "idle" | "submitting" | "success" | "error"; message?: string };
|
||
|
||
export default function ContactPage() {
|
||
const [startedAt, setStartedAt] = useState<string>("");
|
||
const [status, setStatus] = useState<Status>({ state: "idle" });
|
||
|
||
useEffect(() => {
|
||
setStartedAt(String(Date.now()));
|
||
}, []);
|
||
|
||
const disabledEarly = useMemo(() => {
|
||
if (!startedAt) return true;
|
||
const min = Number(process.env.NEXT_PUBLIC_CONTACT_MIN_SUBMIT_SECONDS ?? 3) || 3;
|
||
return Date.now() - Number(startedAt) < min * 1000;
|
||
}, [startedAt]);
|
||
|
||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
setStatus({ state: "submitting" });
|
||
|
||
const form = e.currentTarget;
|
||
const formData = new FormData(form);
|
||
const payload = Object.fromEntries(formData.entries());
|
||
|
||
try {
|
||
const res = await fetch("/api/contact", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const json = (await res.json()) as { ok: boolean; error?: string };
|
||
if (!res.ok || !json.ok) throw new Error(json.error || "Failed");
|
||
setStatus({ state: "success", message: "Thanks! We’ll get back to you shortly." });
|
||
form.reset();
|
||
setStartedAt(String(Date.now())); // reset time trap
|
||
} catch {
|
||
setStatus({ state: "error", message: "Could not send. Please try again." });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="container py-12">
|
||
<h1 className="text-3xl font-semibold tracking-tight">Contact</h1>
|
||
<p className="mt-2 text-muted-foreground">
|
||
Tell us what you want fixed or managed. We’ll confirm scope and start fast.
|
||
</p>
|
||
|
||
<form onSubmit={onSubmit} className="mt-8 space-y-5">
|
||
{/* Honeypot (hidden from users & screen readers) */}
|
||
<div aria-hidden className="hidden">
|
||
<label htmlFor="website">Website</label>
|
||
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
|
||
</div>
|
||
|
||
{/* Time trap */}
|
||
<input type="hidden" name="startedAt" value={startedAt} readOnly />
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="name" className="text-sm font-medium">
|
||
Name<span className="text-red-600"> *</span>
|
||
</label>
|
||
<input
|
||
id="name"
|
||
name="name"
|
||
required
|
||
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
|
||
placeholder="Jane Doe"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="email" className="text-sm font-medium">
|
||
Email<span className="text-red-600"> *</span>
|
||
</label>
|
||
<input
|
||
id="email"
|
||
name="email"
|
||
type="email"
|
||
required
|
||
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
|
||
placeholder="jane@company.com"
|
||
inputMode="email"
|
||
autoComplete="email"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="company" className="text-sm font-medium">
|
||
Company
|
||
</label>
|
||
<input
|
||
id="company"
|
||
name="company"
|
||
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
|
||
placeholder="Company B.V."
|
||
autoComplete="organization"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="message" className="text-sm font-medium">
|
||
What do you need?<span className="text-red-600"> *</span>
|
||
</label>
|
||
<textarea
|
||
id="message"
|
||
name="message"
|
||
required
|
||
rows={6}
|
||
className="rounded-xl border bg-white px-3 py-2 outline-none ring-2 ring-transparent focus:ring-primary"
|
||
placeholder="Briefly describe the problem or goal. Links are optional."
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-start justify-between gap-4">
|
||
<p className="text-xs text-muted-foreground">
|
||
By submitting, you agree we can contact you about your request. We don’t sell data.
|
||
</p>
|
||
<button
|
||
type="submit"
|
||
disabled={status.state === "submitting" || disabledEarly}
|
||
className="inline-flex min-w-[9rem] items-center justify-center rounded-xl bg-primary px-4 py-2 text-primary-foreground transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{status.state === "submitting" ? "Sending..." : "Send"}
|
||
</button>
|
||
</div>
|
||
|
||
{status.state === "success" && (
|
||
<div role="status" className="rounded-xl border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
||
{status.message}
|
||
</div>
|
||
)}
|
||
{status.state === "error" && (
|
||
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||
{status.message}
|
||
</div>
|
||
)}
|
||
</form>
|
||
|
||
<div className="mt-10 text-sm text-muted-foreground">
|
||
Prefer email? Reach us at <a className="underline" href={`mailto:${site.contact.email}`}>{site.contact.email}</a>.
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|