From 9be0527a46dbfcd2aba291dd72e6bfa30647cb62 Mon Sep 17 00:00:00 2001 From: makearmy Date: Mon, 22 Sep 2025 16:11:08 -0400 Subject: [PATCH] added Success page for submissions --- app/api/submit/settings/success/page.tsx | 72 ++++++ app/components/forms/SettingsSubmit.tsx | 312 +++++++++-------------- 2 files changed, 192 insertions(+), 192 deletions(-) create mode 100644 app/api/submit/settings/success/page.tsx diff --git a/app/api/submit/settings/success/page.tsx b/app/api/submit/settings/success/page.tsx new file mode 100644 index 00000000..ae93d427 --- /dev/null +++ b/app/api/submit/settings/success/page.tsx @@ -0,0 +1,72 @@ +// app/submit/settings/success/page.tsx +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useMemo } from "react"; + +type Target = + | "settings_fiber" + | "settings_co2gan" + | "settings_co2gal" + | "settings_uv"; + +const TARGET_TO_LIST: Record = { + settings_fiber: "/fiber-settings", + settings_co2gan: "/co2-gantry-settings", + settings_co2gal: "/co2-galvo-settings", + settings_uv: "/uv-settings", +}; + +const TARGET_LABEL: Record = { + settings_fiber: "Fiber", + settings_co2gan: "CO₂ Gantry", + settings_co2gal: "CO₂ Galvo", + settings_uv: "UV", +}; + +export default function SuccessPage() { + const sp = useSearchParams(); + const id = (sp.get("id") || "").trim(); + const target = (sp.get("target") || "settings_fiber") as Target; + + const listHref = TARGET_TO_LIST[target] || "/projects"; + const targetLabel = TARGET_LABEL[target] || "Settings"; + + const title = useMemo( + () => `Settings Submitted${id ? ` (#${id})` : ""}`, + [id] + ); + + return ( +
+
+

{title}

+

+ Your submission was received successfully. +

+
+ +
+ + Submit Another ({targetLabel}) + + + Go to {targetLabel} Database + +
+ + {id ? ( +

+ Reference ID: {id} +

+ ) : null} +
+ ); +} diff --git a/app/components/forms/SettingsSubmit.tsx b/app/components/forms/SettingsSubmit.tsx index 0128bbd6..2aaafa50 100644 --- a/app/components/forms/SettingsSubmit.tsx +++ b/app/components/forms/SettingsSubmit.tsx @@ -2,15 +2,11 @@ import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; +import { useRouter, useSearchParams } from "next/navigation"; -/** Targets map 1:1 with your Directus collections */ type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; type Opt = { id: string; label: string }; -/* ───────────────────────────────────────────────────────────── - * Generic hook to fetch options with client-side filter - * Expects endpoint to return: { data: Array<{id,label}> } - * ──────────────────────────────────────────────────────────── */ function useOptions(path: string) { const [opts, setOpts] = useState([]); const [loading, setLoading] = useState(false); @@ -23,7 +19,6 @@ function useOptions(path: string) { fetch(url, { cache: "no-store" }) .then((r) => r.json()) .then((j) => { if (alive) setOpts((j?.data as Opt[]) ?? []); }) - .catch(() => { if (alive) setOpts([]); }) .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, [path, q]); @@ -31,9 +26,6 @@ function useOptions(path: string) { return { opts, loading, setQ }; } -/* ───────────────────────────────────────────────────────────── - * Filterable select with optional "required" client rule - * ──────────────────────────────────────────────────────────── */ function FilterableSelect({ label, name, register, options, loading, onQuery, placeholder = "—", required = false, }: { @@ -57,14 +49,16 @@ function FilterableSelect({ return (
- + setFilter(e.target.value)} /> - {filtered.map((o) => )} @@ -72,9 +66,6 @@ function FilterableSelect({ ); } -/* ───────────────────────────────────────────────────────────── - * Checkbox - * ──────────────────────────────────────────────────────────── */ function BoolBox({ label, name, register }:{ label: string; name: string; register: UseFormRegister; }) { @@ -85,87 +76,17 @@ function BoolBox({ label, name, register }:{ ); } -/* ───────────────────────────────────────────────────────────── - * Polished File Picker (with preview + filename) - * NOT registered into RHF; managed via local state. - * Use `onFile` to push the selected File up to the parent. - * ──────────────────────────────────────────────────────────── */ -function FilePicker({ - label, - required, - onFile, - accept, -}: { - label: string; - required?: boolean; - onFile: (f: File | null) => void; - accept?: string; -}) { - const [file, setFile] = useState(null); - const [url, setUrl] = useState(null); - - function handleChange(e: React.ChangeEvent) { - const f = e.target.files?.[0] || null; - setFile(f); - onFile(f); - if (url) URL.revokeObjectURL(url); - setUrl(f ? URL.createObjectURL(f) : null); - } - - function clear() { - setFile(null); - onFile(null); - if (url) URL.revokeObjectURL(url); - setUrl(null); - } - - const inputId = `fp-${label.replace(/\s+/g, "-").toLowerCase()}`; - - return ( -
-
- - {file && ( - - )} -
- - {/* visually hidden native input; we present a custom button */} - -
- -
- {file ? file.name : "No file chosen"} -
-
- - {url && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${label} -
- )} -
- ); -} - -/* ───────────────────────────────────────────────────────────── - * Main form - * ──────────────────────────────────────────────────────────── */ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Target }) { - const [target, setTarget] = useState(initialTarget ?? "settings_fiber"); + const router = useRouter(); + const sp = useSearchParams(); + const initialFromQuery = (sp.get("target") as Target) || initialTarget || "settings_fiber"; + const [target, setTarget] = useState(initialFromQuery); + + // Image inputs (for preview + multipart submit) + const [photoFile, setPhotoFile] = useState(null); + const [screenFile, setScreenFile] = useState(null); + const [photoPreview, setPhotoPreview] = useState(""); + const [screenPreview, setScreenPreview] = useState(""); // Generic lists (alphabetical) const mats = useOptions("material"); @@ -179,11 +100,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ const lens = useOptions(`lens?target=${target}`); // scan vs focus lens by target // Repeater select choices from Directus field config - const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); - const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); - const rasterDither= useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); + const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); + const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); + const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); - const { register, handleSubmit, control, reset, formState: { isSubmitting, errors } } = useForm({ + const { register, handleSubmit, control, reset, formState: { isSubmitting } } = useForm({ defaultValues: { setting_title: "", uploader: "", setting_notes: "", mat: "", mat_coat: "", mat_color: "", mat_opacity: "", @@ -199,40 +120,21 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ const isGantry = target === "settings_co2gan"; const isFiber = target === "settings_fiber"; + // const isUV = target === "settings_uv"; + // const isCO2Gal = target === "settings_co2gal"; - // file state (not part of RHF) - const [photoFile, setPhotoFile] = useState(null); - const [screenFile, setScreenFile] = useState(null); - const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null); - - // helpers function num(v: any) { return (v === "" || v == null) ? null : Number(v); } const bool = (v: any) => !!v; async function onSubmit(values: any) { - setSubmitMsg(null); - - // client-side requireds (extra guardrails) - const missing: string[] = []; - if (!photoFile) missing.push("photo"); - if (!values.source) missing.push("laser source"); - if (!values.lens) missing.push("lens"); - if (values.focus === "" || values.focus === undefined || values.focus === null) missing.push("focus"); - for (const k of ["mat","mat_coat","mat_color","mat_opacity"]) { - if (!values[k]) missing.push(k); - } - if (!values.setting_title) missing.push("title"); - if (!values.uploader) missing.push("uploader"); - if (isFiber) { - if (!values.laser_soft) missing.push("software"); - if (values.repeat_all === "" || values.repeat_all === undefined || values.repeat_all === null) missing.push("repeat_all"); - } - if (missing.length) { - setSubmitMsg({ ok: false, text: `Missing required field(s): ${missing.join(", ")}` }); + // Browser validation for required photo + if (!photoFile) { + // Let the native required attribute handle focus/UX, + // but this is a safety net in case the browser skips it. + (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); return; } - // Build server payload (numbers coerced on server too) const payload: any = { target, setting_title: values.setting_title, @@ -308,42 +210,53 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ payload.repeat_all = num(values.repeat_all); } - // Use multipart/form-data with JSON payload + files - const form = new FormData(); - form.set("payload", JSON.stringify(payload)); - if (photoFile) form.set("photo", photoFile, photoFile.name); - if (screenFile) form.set("screen", screenFile, screenFile.name); - - const res = await fetch("/api/submit/settings", { - method: "POST", - body: form, - }); - - const text = await res.text(); - let data: any = {}; - try { data = text ? JSON.parse(text) : {}; } catch { /* non-JSON error page */ } - - if (!res.ok) { - setSubmitMsg({ ok: false, text: `Submission failed: ${data?.error || res.statusText}` }); - return; + // Decide JSON vs multipart (include images when present) + let res: Response; + if (photoFile || screenFile) { + const form = new FormData(); + form.set("payload", JSON.stringify(payload)); + if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); + if (screenFile) form.set("screen", screenFile, screenFile.name || "screen"); + res = await fetch("/api/submit/settings", { method: "POST", body: form }); + } else { + res = await fetch("/api/submit/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); } - setSubmitMsg({ ok: true, text: `Submitted! ID: ${data?.id ?? "unknown"}` }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || "Submission failed"); - // reset form (keep target) - reset({ - setting_title: "", uploader: "", setting_notes: "", - mat: "", mat_coat: "", mat_color: "", mat_opacity: "", - mat_thickness: "", source: "", lens: "", focus: "", - laser_soft: "", repeat_all: "", - fill_settings: [], line_settings: [], raster_settings: [], - }); + // Clear form + file previews + reset(); setPhotoFile(null); setScreenFile(null); + setPhotoPreview(""); + setScreenPreview(""); + + // Redirect to success page (don’t show alert) + const id = data?.id ? String(data.id) : ""; + router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`); + } + + // File input helpers + function onPick( + file: File | null, + setFile: (f: File | null) => void, + setPreview: (s: string) => void + ) { + setFile(file); + if (!file) { setPreview(""); return; } + const reader = new FileReader(); + reader.onload = () => setPreview(String(reader.result || "")); + reader.readAsDataURL(file); } return (
+ {/* Target + (fiber) software */}
@@ -368,49 +281,83 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ options={soft.opts} loading={soft.loading} onQuery={soft.setQ} - required + required={true} />
)}
-
- {/* identity */} + + {/* Title / Uploader */}
- + - {errors.setting_title &&

Title is required

}
- + - {errors.uploader &&

Uploader is required

}
+ {/* Images */} +
+
+ + onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} + /> +

+ {photoFile ? <>Selected: {photoFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."} +

+ {photoPreview ? ( + Result preview + ) : null} +
+
+ + onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} + /> +

+ {screenFile ? <>Selected: {screenFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."} +

+ {screenPreview ? ( + Settings preview + ) : null} +
+
+ + {/* Notes */}