// app/vault/[uuid]/edit/page.tsx "use client"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; type BuildItemDto = { id?: string; uuid?: string; slot?: string; // categoryId in your MVP position?: number | null; quantity?: number | null; productId?: string | null; productName?: string | null; productBrand?: string | null; productImageUrl?: string | null; }; type BuildDto = { id: string; uuid: string; title: string; description?: string | null; isPublic?: boolean | null; createdAt?: string | null; updatedAt?: string | null; items?: BuildItemDto[] | null; // Option B: metadata fields on Build (or related BuildProfile) platform?: string | null; caliber?: string | null; // purpose?: string | null; buildClass?: string | null; tags?: string[] | null; coverImageUrl?: string | null; }; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; // ----------------------------- // Controlled vocab (platform + caliber) // ----------------------------- const PLATFORM_OPTIONS = ["AR-15", "AR-10", "AR-9"] as const; type PlatformOption = (typeof PLATFORM_OPTIONS)[number] | ""; /** * Keep this intentionally small and controlled for now: * - Only calibers relevant to AR-15 / AR-10 / AR-9 * - Easy future expansion: add strings to the arrays below * - If you later want “canonical” values vs “labels”, we can switch to { value, label } objects. */ const CALIBERS_BY_PLATFORM: Record< Exclude, readonly string[] > = { "AR-15": [ "5.56 NATO", ".223 Rem", ".300 Blackout", "6.5 Grendel", "6mm ARC", "7.62x39", ".224 Valkyrie", "350 Legend", "6.8 SPC", "458 SOCOM", ], "AR-10": [ "7.62 NATO", ".308 Win", "6.5 Creedmoor", "6mm Creedmoor", ".260 Rem", ".243 Win", "8.6 Blackout", ], "AR-9": ["9mm", "10mm", ".40 S&W", ".45 ACP"], } as const; type CaliberOption = string | ""; // ----------------------------- // Build Class (controlled list) // ----------------------------- const BUILD_CLASS_OPTIONS = [ "Home Defense", "Duty / Patrol", "Training", "Competition", "Hunting", "Range / Fun", ] as const; type BuildClassOption = (typeof BUILD_CLASS_OPTIONS)[number] | ""; function fmt(d?: string | null) { if (!d) return "—"; const dt = new Date(d); if (Number.isNaN(dt.getTime())) return d; return dt.toLocaleString(); } function isUuid(v: string) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( v ); } export default function EditBuildPage() { const router = useRouter(); const params = useParams(); const { user, loading, getAuthHeaders } = useAuth(); const uuid = String(params?.uuid ?? ""); const authed = !!user; const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]); const [busy, setBusy] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [build, setBuild] = useState(null); // Editable submission/profile fields const [profile, setProfile] = useState<{ title: string; description: string; isPublic: boolean; platform: PlatformOption; caliber: CaliberOption; buildClass: BuildClassOption; tagsText: string; // comma-separated for MVP coverImageUrl: string; } | null>(null); // Redirect to login if not authed useEffect(() => { if (!loading && !authed) router.replace("/login"); }, [loading, authed, router]); // Load build useEffect(() => { if (loading || !authed) return; if (!uuid || !isUuid(uuid)) { setError("Invalid build id."); return; } let cancelled = false; (async () => { setBusy(true); setError(null); try { // NOTE: ownership / public checks happen server-side const res = await fetch( `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`, { credentials: "include", } ); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(txt || `Load failed (${res.status})`); } const dto = (await res.json()) as BuildDto; if (cancelled) return; setBuild(dto); const normalizedPlatform: PlatformOption = PLATFORM_OPTIONS.includes( (dto.platform ?? "") as any ) ? (dto.platform as PlatformOption) : ""; const allowedCalibers = normalizedPlatform ? CALIBERS_BY_PLATFORM[normalizedPlatform] : []; const normalizedCaliber = dto.caliber && allowedCalibers.includes(dto.caliber) ? dto.caliber : ""; setProfile({ title: dto.title ?? "", description: dto.description ?? "", isPublic: !!dto.isPublic, platform: normalizedPlatform, caliber: normalizedCaliber, buildClass: (BUILD_CLASS_OPTIONS.includes( (dto.buildClass ?? "") as any ) ? (dto.buildClass as any) : "") as BuildClassOption, tagsText: Array.isArray(dto.tags) ? dto.tags.join(", ") : "", coverImageUrl: dto.coverImageUrl ?? "", }); } catch (e: any) { if (!cancelled) setError(e?.message || "Failed to load build"); } finally { if (!cancelled) setBusy(false); } })(); return () => { cancelled = true; }; }, [loading, authed, uuid, headers]); // Derived caliber options based on selected platform const caliberOptions = useMemo(() => { if (!profile?.platform) return []; return ( CALIBERS_BY_PLATFORM[profile.platform as Exclude] ?? [] ); }, [profile?.platform]); // If platform changes, ensure caliber is valid for that platform useEffect(() => { if (!profile) return; if (!profile.platform) return; const allowed = CALIBERS_BY_PLATFORM[profile.platform as Exclude] ?? []; if (!profile.caliber) return; if (!allowed.includes(profile.caliber)) { setProfile((p) => (p ? { ...p, caliber: "" } : p)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [profile?.platform]); // intentionally only platform const save = async () => { if (!profile) return; if (!uuid || !isUuid(uuid)) return; const title = profile.title.trim(); if (!title) { setError("Title is required."); return; } setSaving(true); setError(null); try { const payload = { title, description: profile.description?.trim() || null, isPublic: !!profile.isPublic, // Backend expects buildClass (not purpose) buildClass: profile.buildClass || null, caliber: profile.caliber || null, coverImageUrl: profile.coverImageUrl?.trim() || null, // Backend expects tags: string[] tags: profile.tagsText .split(",") .map((t) => t.trim()) .filter(Boolean), // IMPORTANT: if your backend overwrites items when provided, // leave items undefined unless you intend to edit items here. // If your backend REQUIRES items, uncomment and map from build.items: // items: (build?.items ?? []).map((it) => ({ // productId: it.productId ? Number(it.productId) : null, // slot: it.slot ?? "", // position: it.position ?? 0, // quantity: it.quantity ?? 1, // })).filter((it) => it.productId && it.slot), }; const res = await fetch( `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`, { method: "PUT", headers: { "Content-Type": "application/json", ...headers, }, credentials: "include", body: JSON.stringify(payload), } ); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(txt || `Save failed (${res.status})`); } const updated = (await res.json().catch(() => null)) as BuildDto | null; if (updated?.uuid) setBuild(updated); router.replace("/vault"); } catch (e: any) { setError(e?.message || "Save failed"); } finally { setSaving(false); } }; if (loading || busy) { return (
Loading…
); } if (!authed) return null; return (

Edit Build

{uuid}
Updated: {fmt(build?.updatedAt)}
Open in Builder ← Vault
{error && (
{error}
)} {!profile ? (
No build loaded.
) : (
{/* Left: form */}
Submission Details
Controlled fields keep the community feed clean + filterable.
{/* Title */}
setProfile((p) => (p ? { ...p, title: e.target.value } : p)) } className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60" placeholder='e.g. "Budget 16” General Purpose AR-15"' />
{/* Platform */}
Controlled list (prevents messy feed data).
{/* Caliber */}
Options are tied to platform. Update{" "} CALIBERS_BY_PLATFORM to add more.
{/* Build Class */}
{/* Tags */}
setProfile((p) => p ? { ...p, tagsText: e.target.value } : p ) } className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60" placeholder="budget, general purpose, suppressed" />
Comma-separated for MVP.
{/* Cover image URL */}
setProfile((p) => p ? { ...p, coverImageUrl: e.target.value } : p ) } className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60" placeholder="https://…" />
Later we’ll replace this with uploads.
{/* Description */}