Files
professional-website/src/app/api/contact/route.ts
Jason Woltje 486bbc8cf8
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/pr/web Pipeline failed
feat(site): port stitch design system + seed-ready content model
Ports the "Technical Editorial" design sample into real TSX wired to
Payload globals/collections. Home/About/Projects (list+detail)/Contact
pages render against Payload data. Expands schemas (Home principles,
About timeline/skills/gear, Contact channels) to cover the full design
surface. Adds seed script that populates realistic AI-drafted content
for first boot. Defers writing/resume routes per scope cut.

- Design tokens: Material-3 palette + Space Grotesk/Inter typography
  scale + dot-grid + glassmorphism utilities
- Shared layout: Nav, Footer, StatusTerminal, GridOverlay, Button,
  TechChip in src/components/site
- Schemas: expand 5 globals + 6 collections; add auto-slug hook
- Seed: scripts/seed.ts — idempotent upsert for media, categories,
  6 projects, 8 gear, 3 posts, 5 globals; generates placeholder admin
- Contact: form + /api/contact route with optional Turnstile verify
- Rename TURNSTILE_SITE_KEY -> NEXT_PUBLIC_TURNSTILE_SITE_KEY (client)
- Remove dead src/components/SiteHeader|SiteFooter|StatusTerminal
2026-04-14 18:57:53 -05:00

94 lines
2.7 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface ContactBody {
name?: unknown;
email?: unknown;
message?: unknown;
turnstileToken?: unknown;
}
interface TurnstileResponse {
success: boolean;
"error-codes"?: string[];
}
async function verifyTurnstile(token: string, ip: string): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) return true;
const body = new URLSearchParams({
secret,
response: token,
remoteip: ip,
});
const res = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{ method: "POST", body },
);
const data = (await res.json()) as TurnstileResponse;
return data.success === true;
}
export async function POST(req: NextRequest) {
const raw = (await req.json().catch(() => null)) as ContactBody | null;
if (!raw) {
return NextResponse.json({ ok: false, error: "Invalid request body." }, { status: 400 });
}
const { name, email, message, turnstileToken } = raw;
if (typeof name !== "string" || !name.trim()) {
return NextResponse.json({ ok: false, error: "Name is required." }, { status: 400 });
}
if (typeof email !== "string" || !email.trim()) {
return NextResponse.json({ ok: false, error: "Email is required." }, { status: 400 });
}
if (typeof message !== "string" || !message.trim()) {
return NextResponse.json({ ok: false, error: "Message is required." }, { status: 400 });
}
const forwardedFor = req.headers.get("x-forwarded-for") ?? "";
const ip = forwardedFor.split(",")[0]?.trim() ?? "";
if (process.env.TURNSTILE_SECRET_KEY && typeof turnstileToken === "string") {
const valid = await verifyTurnstile(turnstileToken, ip);
if (!valid) {
return NextResponse.json(
{ ok: false, error: "Spam protection check failed. Please try again." },
{ status: 400 },
);
}
}
try {
const payload = await getPayload({ config });
await payload.create({
collection: "contactSubmissions",
data: {
name: name.trim(),
email: email.trim(),
message: message.trim(),
turnstileVerified: Boolean(
process.env.TURNSTILE_SECRET_KEY && typeof turnstileToken === "string",
),
submittedAt: new Date().toISOString(),
ip: ip || undefined,
},
});
return NextResponse.json({ ok: true }, { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 500 });
}
}