Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 32 additions & 44 deletions app/api/submit/route.ts
Original file line number Diff line number Diff line change
@@ -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 <onboarding@resend.dev>"

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.
Expand Down
73 changes: 60 additions & 13 deletions app/submit/SubmitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,46 @@

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<FormState>("idle")
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([])

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<HTMLFormElement>) {
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)

const get = (k: string) => (data.get(k) as string | null)?.trim() || ""
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"),
Expand All @@ -53,20 +60,35 @@ 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",
headers: { "Content-Type": "application/json" },
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
}
Expand Down Expand Up @@ -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] ? <div style={errStyle}>{fieldErrors[name]}</div> : null

if (state === "success") {
return (
<div
Expand Down Expand Up @@ -186,6 +219,7 @@ export function SubmitForm() {
onFocus={focusBorder}
onBlur={blurBorder}
/>
{fieldError("skill_name")}
</div>

<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px", marginBottom: "24px" }}>
Expand All @@ -200,6 +234,7 @@ export function SubmitForm() {
onFocus={focusBorder}
onBlur={blurBorder}
/>
{fieldError("submitter_name")}
</div>
<div>
<label style={labelStyle}>Email *</label>
Expand All @@ -212,6 +247,7 @@ export function SubmitForm() {
onFocus={focusBorder}
onBlur={blurBorder}
/>
{fieldError("submitter_email")}
</div>
</div>

Expand All @@ -228,6 +264,7 @@ export function SubmitForm() {
onBlur={blurBorder}
/>
<div style={hintStyle}>Max 120 characters</div>
{fieldError("short_description")}
</div>

<div style={fieldStyle}>
Expand All @@ -247,6 +284,7 @@ export function SubmitForm() {
onBlur={blurBorder}
/>
<div style={hintStyle}>Markdown supported. Be specific about what the skill does.</div>
{fieldError("long_description")}
</div>

{/* Technical */}
Expand Down Expand Up @@ -278,6 +316,7 @@ export function SubmitForm() {
onFocus={focusBorder}
onBlur={blurBorder}
/>
{fieldError("version")}
</div>
<div>
<label style={labelStyle}>Category *</label>
Expand All @@ -297,6 +336,7 @@ export function SubmitForm() {
))}
<option value="Other">Other</option>
</select>
{fieldError("category")}
</div>
</div>

Expand All @@ -311,6 +351,7 @@ export function SubmitForm() {
onFocus={focusBorder}
onBlur={blurBorder}
/>
{fieldError("install_command")}
</div>

<div style={fieldStyle}>
Expand Down Expand Up @@ -339,6 +380,7 @@ export function SubmitForm() {
</button>
))}
</div>
{fieldError("platforms")}
</div>

<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px", marginBottom: "24px" }}>
Expand All @@ -352,6 +394,7 @@ export function SubmitForm() {
onFocus={focusBorder}
onBlur={blurBorder}
/>
{fieldError("repo_url")}
</div>
<div>
<label style={labelStyle}>Documentation URL</label>
Expand All @@ -363,6 +406,7 @@ export function SubmitForm() {
onFocus={focusBorder}
onBlur={blurBorder}
/>
{fieldError("docs_url")}
</div>
</div>

Expand Down Expand Up @@ -411,6 +455,7 @@ export function SubmitForm() {
onBlur={blurBorder}
/>
<div style={hintStyle}>Leave blank for free skills</div>
{fieldError("price_usd")}
</div>
</div>

Expand All @@ -426,17 +471,19 @@ export function SubmitForm() {
onBlur={blurBorder}
/>
<div style={hintStyle}>Comma-separated. Helps with discoverability.</div>
{fieldError("tags")}
</div>

{/* Error */}
{errorMsg && (
<div
role="alert"
style={{
fontFamily: "var(--font-jetbrains-mono), monospace",
fontSize: "12px",
color: "#ffffff",
backgroundColor: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.3)",
color: "#ff5c5c",
backgroundColor: "rgba(255,92,92,0.08)",
border: "1px solid rgba(255,92,92,0.4)",
padding: "10px 12px",
borderRadius: "4px",
marginBottom: "16px",
Expand Down
Loading