diff --git a/app/(builder)/builder/page.tsx b/app/(builder)/builder/page.tsx index 53ef847..c95cf75 100644 --- a/app/(builder)/builder/page.tsx +++ b/app/(builder)/builder/page.tsx @@ -12,16 +12,25 @@ * Notes for devs: * - We intentionally keep ONE save-to-vault handler: handleSaveAs() * - Toast is used for clear user feedback (save success/fail) + * + * Auth note: + * - /api/v1/builds/me/* requires JWT. + * - The builder can render before AuthContext hydrates from localStorage, so we + * guard “load build” calls until auth is ready to avoid a 401. */ import { useMemo, useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; +import { X, Link2, Square, CheckSquare } from "lucide-react"; + +import { useApi } from "@/lib/api"; +import { useAuth } from "@/context/AuthContext"; + import { CATEGORIES } from "@/data/gunbuilderParts"; import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; import type { CategoryId, Part } from "@/types/gunbuilder"; import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings"; -import { X, Link2, Square, CheckSquare } from "lucide-react"; // ✅ Centralized overlap rules + helpers import OverlapChip from "@/components/builder/OverlapChip"; @@ -132,6 +141,11 @@ export default function GunbuilderPage() { const searchParams = useSearchParams(); const router = useRouter(); + const api = useApi(); + + // ✅ Auth: we need the token ready before calling /builds/me/* + const { token, loading: authLoading, getAuthHeaders } = useAuth(); + // ----------------------------- // Save As… modal state (Vault variants) // ----------------------------- @@ -509,13 +523,30 @@ export default function GunbuilderPage() { if (!uuidToLoad) return; + // ✅ Wait for AuthProvider hydration before calling /me endpoints. + if (authLoading) return; + + // ✅ If not logged in, don't spam the backend; show a helpful message instead. + if (!token) { + setShareStatus("Please log in to load builds from your Vault."); + window.setTimeout(() => setShareStatus(null), 4500); + return; + } + const run = async () => { try { setShareStatus("Loading build…"); + // NOTE: using fetch here avoids any “stale api instance” issues and lets us + // explicitly attach JWT headers. const res = await fetch( `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`, - { credentials: "include" } + { + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + } ); if (!res.ok) { @@ -551,8 +582,14 @@ export default function GunbuilderPage() { }; run(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams, router]); + }, [ + searchParams, + router, + authLoading, + token, + getAuthHeaders, + // API_BASE_URL is a module constant; no need to include + ]); // ----------------------------- // SAVE AS… (single source of truth for saving to Vault) @@ -584,16 +621,11 @@ export default function GunbuilderPage() { setSaveAsSaving(true); showToast({ type: "info", message: "Saving build…" }); - const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - title, - description: saveAsDesc?.trim() || null, - isPublic: false, - items, - }), + const res = await api.post("/api/v1/builds/me", { + title, + description: saveAsDesc?.trim() || null, + isPublic: false, + items, }); if (!res.ok) { diff --git a/app/vault/[uuid]/edit/page.tsx b/app/vault/[uuid]/edit/page.tsx index a3c3d4a..f1d5c5f 100644 --- a/app/vault/[uuid]/edit/page.tsx +++ b/app/vault/[uuid]/edit/page.tsx @@ -1,305 +1,119 @@ -// app/vault/[uuid]/edit/page.tsx "use client"; -import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; 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; -}; +import { useApi } from "@/lib/api"; 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; + items?: Array<{ + slot: string; + productId: number; + productName?: string | null; + bestPrice?: number | string | null; // backend may serialize BigDecimal as string + }>; }; -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(); + return Number.isNaN(dt.getTime()) ? d : 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() { +export default function VaultBuildEditPage() { + const { uuid } = useParams<{ uuid: string }>(); 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 api = useApi(); + const { token, loading: authLoading } = useAuth(); const [build, setBuild] = useState(null); + const [form, setForm] = useState({ + title: "", + description: "", + isPublic: false, + }); - // Editable submission/profile fields - const [profile, setProfile] = useState<{ - title: string; - description: string; - isPublic: boolean; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [savedMsg, setSavedMsg] = useState(null); - platform: PlatformOption; - caliber: CaliberOption; - buildClass: BuildClassOption; + const authed = !!token; - tagsText: string; // comma-separated for MVP - coverImageUrl: string; - } | null>(null); - - // Redirect to login if not authed + // Load build once auth is ready + token exists useEffect(() => { - if (!loading && !authed) router.replace("/login"); - }, [loading, authed, router]); + if (!uuid) return; + if (authLoading) return; - // Load build - useEffect(() => { - if (loading || !authed) return; - - if (!uuid || !isUuid(uuid)) { - setError("Invalid build id."); + if (!authed) { + setError("Please log in to edit builds."); 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", - } + setError(null); + const res = await api.get( + `/api/v1/builds/me/${encodeURIComponent(uuid)}` ); if (!res.ok) { const txt = await res.text().catch(() => ""); - throw new Error(txt || `Load failed (${res.status})`); + throw new Error(txt || `Failed to load build (${res.status})`); } - const dto = (await res.json()) as BuildDto; + const data = (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 ?? "", + setBuild(data); + setForm({ + title: data.title ?? "", + description: data.description ?? "", + isPublic: Boolean(data.isPublic), }); } catch (e: any) { - if (!cancelled) setError(e?.message || "Failed to load build"); - } finally { - if (!cancelled) setBusy(false); + if (!cancelled) setError(e?.message ?? "Failed to load build"); } })(); return () => { cancelled = true; }; - }, [loading, authed, uuid, headers]); + }, [uuid, authLoading, authed, api]); - // Derived caliber options based on selected platform - const caliberOptions = useMemo(() => { - if (!profile?.platform) return []; + const dirty = useMemo(() => { + if (!build) return false; return ( - CALIBERS_BY_PLATFORM[profile.platform as Exclude] ?? - [] + form.title !== (build.title ?? "") || + form.description !== (build.description ?? "") || + form.isPublic !== Boolean(build.isPublic) ); - }, [profile?.platform]); + }, [build, form]); - // 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); + async function onSave() { + if (!uuid) return; + setBusy(true); + setSavedMsg(null); 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), + title: form.title.trim() || "Untitled Build", + description: form.description.trim() || null, + isPublic: form.isPublic, }; - 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), - } + const res = await api.put( + `/api/v1/builds/me/${encodeURIComponent(uuid)}`, + payload ); if (!res.ok) { @@ -307,341 +121,250 @@ export default function EditBuildPage() { 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"); + const updated = (await res.json()) as BuildDto; + setBuild(updated); + setForm({ + title: updated.title ?? "", + description: updated.description ?? "", + isPublic: Boolean(updated.isPublic), + }); + setSavedMsg("Saved."); + setTimeout(() => setSavedMsg(null), 2000); } catch (e: any) { - setError(e?.message || "Save failed"); + setError(e?.message ?? "Save failed"); } finally { - setSaving(false); + setBusy(false); } - }; - - if (loading || busy) { - return ( -
-
Loading…
-
- ); } - if (!authed) return null; + async function onDelete() { + if (!uuid) return; + + const ok = window.confirm("Delete this build?\n\nThis cannot be undone."); + if (!ok) return; + + setBusy(true); + setError(null); + + try { + const res = await api.del( + `/api/v1/builds/me/${encodeURIComponent(uuid)}` + ); + + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(txt || `Delete failed (${res.status})`); + } + + router.replace("/vault"); + router.refresh(); + } catch (e: any) { + setError(e?.message ?? "Delete failed"); + } finally { + setBusy(false); + } + } return ( -
-
-
-

Edit Build

-
- {uuid} -
-
- Updated: {fmt(build?.updatedAt)} +
+
+
+

Edit Build

+
+ UUID: {uuid}
-
+
- Open in Builder - - ← Vault + +
- {error && ( -
+ {authLoading &&
Loading…
} + + {!authLoading && 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"' - /> + {!authLoading && build && ( +
+ {/* meta */} +
+
+
+ Created: {fmt(build.createdAt)} • Updated:{" "} + {fmt(build.updatedAt)}
- {/* 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 */} -
- -