diff --git a/app/api/submit/route.ts b/app/api/submit/route.ts index 02d4406..ff7fb7c 100644 --- a/app/api/submit/route.ts +++ b/app/api/submit/route.ts @@ -1,74 +1,62 @@ import { NextResponse } from "next/server" import { supabase } from "@/lib/supabase" +import { + validateSubmission, + hasErrors, + type SubmissionInput, +} from "@/lib/submission" -// Submission intake: insert into Supabase, then email a notification. +// Submission intake: validate, insert into Supabase, then email a notification. // Insert is the source of truth — if the email fails, the submission is // still saved and we return success. Email needs RESEND_API_KEY (Vercel env). const NOTIFY_TO = process.env.SUBMISSION_NOTIFY_TO || "hello@vhq.co" const NOTIFY_FROM = process.env.SUBMISSION_NOTIFY_FROM || "Solid State " -type SubmissionInput = { - submitter_name?: string - submitter_email?: string - skill_name?: string - short_description?: string - long_description?: string - version?: string - category?: string - install_command?: string - platforms?: string[] - repo_url?: string | null - docs_url?: string | null - pricing_model?: string - price_usd?: number | null - tags?: string[] -} - -const required: (keyof SubmissionInput)[] = [ - "submitter_name", "submitter_email", "skill_name", - "short_description", "long_description", "version", - "category", "install_command", -] +const str = (v: unknown) => (typeof v === "string" ? v.trim() : "") export async function POST(req: Request) { let body: SubmissionInput try { body = await req.json() } catch { - return NextResponse.json({ error: "Invalid request body." }, { status: 400 }) + return NextResponse.json({ error: "We couldn't read that submission." }, { status: 400 }) } - for (const f of required) { - if (!body[f] || String(body[f]).trim() === "") { - return NextResponse.json({ error: `Missing field: ${f}` }, { status: 400 }) - } - } - if (!Array.isArray(body.platforms) || body.platforms.length === 0) { - return NextResponse.json({ error: "Pick at least one compatible platform." }, { status: 400 }) + // Authoritative validation — the client runs the same rules for fast + // feedback, but the server never trusts that it did. + const errors = validateSubmission(body) + if (hasErrors(errors)) { + const first = Object.values(errors)[0] + return NextResponse.json({ error: first, errors }, { status: 400 }) } + const pricingModel = str(body.pricing_model) || "free" const row = { - submitter_name: body.submitter_name, - submitter_email: body.submitter_email, - skill_name: body.skill_name, - short_description: body.short_description, - long_description: body.long_description, - version: body.version, - category: body.category, - install_command: body.install_command, + submitter_name: str(body.submitter_name), + submitter_email: str(body.submitter_email), + skill_name: str(body.skill_name), + short_description: str(body.short_description), + long_description: str(body.long_description), + version: str(body.version), + category: str(body.category), + install_command: str(body.install_command), platforms: body.platforms, - repo_url: body.repo_url || null, - docs_url: body.docs_url || null, - pricing_model: body.pricing_model || "free", - price_usd: body.price_usd ?? null, + repo_url: str(body.repo_url) || null, + docs_url: str(body.docs_url) || null, + pricing_model: pricingModel, + price_usd: pricingModel === "paid" ? (body.price_usd ?? null) : null, tags: Array.isArray(body.tags) ? body.tags : [], } const { error } = await supabase.from("submissions").insert(row) if (error) { console.error("submission insert failed:", error) - return NextResponse.json({ error: error.message || "Could not save submission." }, { status: 500 }) + // Don't leak DB internals to the client — log them, return brand voice. + return NextResponse.json( + { error: "Something broke on our end saving that. Try again in a moment." }, + { status: 500 } + ) } // Fire-and-forget notification. Never blocks or fails the submission. diff --git a/app/submit/SubmitForm.tsx b/app/submit/SubmitForm.tsx index 68ca8e5..68fe416 100644 --- a/app/submit/SubmitForm.tsx +++ b/app/submit/SubmitForm.tsx @@ -2,31 +2,38 @@ import { useState } from "react" import { CATEGORIES, PLATFORMS } from "@/lib/skills" +import { + validateSubmission, + hasErrors, + type FieldErrors, + type SubmissionInput, +} from "@/lib/submission" type FormState = "idle" | "submitting" | "success" | "error" export function SubmitForm() { const [state, setState] = useState("idle") const [errorMsg, setErrorMsg] = useState(null) + const [fieldErrors, setFieldErrors] = useState({}) const [selectedPlatforms, setSelectedPlatforms] = useState([]) function togglePlatform(p: string) { setSelectedPlatforms((prev) => prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p] ) + // Clear the platform error the moment the user acts on it. + setFieldErrors((prev) => { + if (!prev.platforms) return prev + const next = { ...prev } + delete next.platforms + return next + }) } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setErrorMsg(null) - if (selectedPlatforms.length === 0) { - setErrorMsg("Pick at least one compatible platform.") - return - } - - setState("submitting") - const form = e.currentTarget const data = new FormData(form) @@ -34,7 +41,7 @@ export function SubmitForm() { const priceRaw = get("price_usd") const tagsRaw = get("tags") - const submission = { + const submission: SubmissionInput = { submitter_name: get("submitter_name"), submitter_email: get("submitter_email"), skill_name: get("skill_name"), @@ -53,6 +60,18 @@ export function SubmitForm() { : [], } + // Same rules the server enforces — fail fast, no round-trip. + const clientErrors = validateSubmission(submission) + if (hasErrors(clientErrors)) { + setFieldErrors(clientErrors) + setErrorMsg("A few fields need fixing before this can ship.") + setState("error") + return + } + + setFieldErrors({}) + setState("submitting") + try { const res = await fetch("/api/submit", { method: "POST", @@ -60,13 +79,16 @@ export function SubmitForm() { body: JSON.stringify(submission), }) if (!res.ok) { - const { error } = await res.json().catch(() => ({ error: null })) + const { error, errors } = await res + .json() + .catch(() => ({ error: null, errors: null })) + if (errors) setFieldErrors(errors as FieldErrors) setErrorMsg(error || "Something went wrong. Try again.") setState("error") return } } catch { - setErrorMsg("Network error. Try again.") + setErrorMsg("Network dropped. Check your connection and try again.") setState("error") return } @@ -118,6 +140,17 @@ export function SubmitForm() { marginTop: "5px", } as React.CSSProperties + const errStyle = { + fontFamily: "var(--font-jetbrains-mono), monospace", + fontSize: "10px", + color: "#ff5c5c", + marginTop: "5px", + } as React.CSSProperties + + // Inline, field-level error under an input. Renders nothing when clean. + const fieldError = (name: keyof FieldErrors) => + fieldErrors[name] ?
{fieldErrors[name]}
: null + if (state === "success") { return (
+ {fieldError("skill_name")}
@@ -200,6 +234,7 @@ export function SubmitForm() { onFocus={focusBorder} onBlur={blurBorder} /> + {fieldError("submitter_name")}
@@ -212,6 +247,7 @@ export function SubmitForm() { onFocus={focusBorder} onBlur={blurBorder} /> + {fieldError("submitter_email")}
@@ -228,6 +264,7 @@ export function SubmitForm() { onBlur={blurBorder} />
Max 120 characters
+ {fieldError("short_description")}
@@ -247,6 +284,7 @@ export function SubmitForm() { onBlur={blurBorder} />
Markdown supported. Be specific about what the skill does.
+ {fieldError("long_description")}
{/* Technical */} @@ -278,6 +316,7 @@ export function SubmitForm() { onFocus={focusBorder} onBlur={blurBorder} /> + {fieldError("version")}
@@ -297,6 +336,7 @@ export function SubmitForm() { ))} + {fieldError("category")}
@@ -311,6 +351,7 @@ export function SubmitForm() { onFocus={focusBorder} onBlur={blurBorder} /> + {fieldError("install_command")}
@@ -339,6 +380,7 @@ export function SubmitForm() { ))}
+ {fieldError("platforms")}
@@ -352,6 +394,7 @@ export function SubmitForm() { onFocus={focusBorder} onBlur={blurBorder} /> + {fieldError("repo_url")}
@@ -363,6 +406,7 @@ export function SubmitForm() { onFocus={focusBorder} onBlur={blurBorder} /> + {fieldError("docs_url")}
@@ -411,6 +455,7 @@ export function SubmitForm() { onBlur={blurBorder} />
Leave blank for free skills
+ {fieldError("price_usd")} @@ -426,17 +471,19 @@ export function SubmitForm() { onBlur={blurBorder} />
Comma-separated. Helps with discoverability.
+ {fieldError("tags")} {/* Error */} {errorMsg && (
> + +export const PRICING_MODELS = ["free", "paid"] as const + +// Field length ceilings. short_description matches the DB check (<= 120). +export const LIMITS = { + submitter_name: 80, + skill_name: 80, + short_description: 120, + long_description: 4000, + long_description_min: 30, + install_command: 200, + version: 32, + max_tags: 12, + tag_length: 40, +} as const + +// Pragmatic email shape check — not RFC-perfect, just "has a local part, an @, +// a domain, and a dot". The real proof is the confirmation email. +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +// Loose semver: 1, 1.0, or 1.0.0 with optional pre-release/build suffix. +const VERSION_RE = /^v?\d+(\.\d+){0,2}([-+][0-9a-z.-]+)?$/i + +function isHttpUrl(value: string): boolean { + try { + const u = new URL(value) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + +const str = (v: unknown) => (typeof v === "string" ? v.trim() : "") + +/** + * Validate a raw submission. Returns field-keyed errors; an empty object means + * the submission is clean. Used on the client for inline feedback and on the + * server as the authoritative gate. + */ +export function validateSubmission(input: SubmissionInput): FieldErrors { + const errors: FieldErrors = {} + + const name = str(input.submitter_name) + if (!name) errors.submitter_name = "Tell us who's submitting." + else if (name.length > LIMITS.submitter_name) + errors.submitter_name = `Keep your name under ${LIMITS.submitter_name} characters.` + + const email = str(input.submitter_email) + if (!email) errors.submitter_email = "We need an email to reach you." + else if (!EMAIL_RE.test(email)) errors.submitter_email = "That email doesn't look right." + + const skillName = str(input.skill_name) + if (!skillName) errors.skill_name = "Your skill needs a name." + else if (skillName.length > LIMITS.skill_name) + errors.skill_name = `Skill names top out at ${LIMITS.skill_name} characters.` + + const shortDesc = str(input.short_description) + if (!shortDesc) errors.short_description = "One line on what it does." + else if (shortDesc.length > LIMITS.short_description) + errors.short_description = `Short description maxes out at ${LIMITS.short_description} characters.` + + const longDesc = str(input.long_description) + if (!longDesc) errors.long_description = "Tell us what it actually does." + else if (longDesc.length < LIMITS.long_description_min) + errors.long_description = `Go deeper — at least ${LIMITS.long_description_min} characters.` + else if (longDesc.length > LIMITS.long_description) + errors.long_description = `That's a lot. Trim it under ${LIMITS.long_description} characters.` + + const version = str(input.version) + if (!version) errors.version = "Ship it with a version." + else if (version.length > LIMITS.version || !VERSION_RE.test(version)) + errors.version = "Use a version like 1.0.0." + + const category = str(input.category) + const allowedCategories = [...CATEGORIES, "Other"] + if (!category) errors.category = "Pick a category." + else if (!allowedCategories.includes(category)) + errors.category = "Pick a category from the list." + + const installCommand = str(input.install_command) + if (!installCommand) errors.install_command = "How do people install it?" + else if (installCommand.length > LIMITS.install_command) + errors.install_command = `Install command is too long (max ${LIMITS.install_command}).` + + const platforms = Array.isArray(input.platforms) ? input.platforms : [] + if (platforms.length === 0) errors.platforms = "Pick at least one platform you've tested on." + else if (!platforms.every((p) => (PLATFORMS as readonly string[]).includes(p))) + errors.platforms = "Unknown platform in the list." + + const repoUrl = str(input.repo_url) + if (repoUrl && !isHttpUrl(repoUrl)) errors.repo_url = "Repository URL must start with http(s)://." + + const docsUrl = str(input.docs_url) + if (docsUrl && !isHttpUrl(docsUrl)) errors.docs_url = "Docs URL must start with http(s)://." + + const pricingModel = str(input.pricing_model) || "free" + if (!(PRICING_MODELS as readonly string[]).includes(pricingModel)) + errors.pricing_model = "Pricing is free or paid." + + const price = input.price_usd + if (pricingModel === "paid") { + if (price == null || Number.isNaN(price)) + errors.price_usd = "Paid skills need a price." + else if (!Number.isFinite(price) || price < 1 || price > 999) + errors.price_usd = "Price has to be between $1 and $999." + } else if (price != null && !Number.isNaN(price)) { + errors.price_usd = "Free skills can't carry a price." + } + + const tags = Array.isArray(input.tags) ? input.tags : [] + if (tags.length > LIMITS.max_tags) + errors.tags = `Keep it to ${LIMITS.max_tags} tags or fewer.` + else if (tags.some((t) => typeof t !== "string" || t.length > LIMITS.tag_length)) + errors.tags = `Each tag stays under ${LIMITS.tag_length} characters.` + + return errors +} + +export function hasErrors(errors: FieldErrors): boolean { + return Object.keys(errors).length > 0 +}