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
This commit is contained in:
93
src/app/api/contact/route.ts
Normal file
93
src/app/api/contact/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user