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
94 lines
2.7 KiB
TypeScript
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 });
|
|
}
|
|
}
|