fixing vault. allow users to delete their builds or update

This commit is contained in:
2025-12-21 07:20:22 -05:00
parent 47d5cf239d
commit 008936ff98
5 changed files with 391 additions and 607 deletions
+46 -14
View File
@@ -12,16 +12,25 @@
* Notes for devs: * Notes for devs:
* - We intentionally keep ONE save-to-vault handler: handleSaveAs() * - We intentionally keep ONE save-to-vault handler: handleSaveAs()
* - Toast is used for clear user feedback (save success/fail) * - 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 { useMemo, useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; 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 { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
import type { CategoryId, Part } from "@/types/gunbuilder"; import type { CategoryId, Part } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings"; import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
import { X, Link2, Square, CheckSquare } from "lucide-react";
// ✅ Centralized overlap rules + helpers // ✅ Centralized overlap rules + helpers
import OverlapChip from "@/components/builder/OverlapChip"; import OverlapChip from "@/components/builder/OverlapChip";
@@ -132,6 +141,11 @@ export default function GunbuilderPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); 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) // Save As… modal state (Vault variants)
// ----------------------------- // -----------------------------
@@ -509,13 +523,30 @@ export default function GunbuilderPage() {
if (!uuidToLoad) return; 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 () => { const run = async () => {
try { try {
setShareStatus("Loading build…"); setShareStatus("Loading build…");
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
// explicitly attach JWT headers.
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`, `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
{ credentials: "include" } {
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
}
); );
if (!res.ok) { if (!res.ok) {
@@ -551,8 +582,14 @@ export default function GunbuilderPage() {
}; };
run(); 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) // SAVE AS… (single source of truth for saving to Vault)
@@ -584,16 +621,11 @@ export default function GunbuilderPage() {
setSaveAsSaving(true); setSaveAsSaving(true);
showToast({ type: "info", message: "Saving build…" }); showToast({ type: "info", message: "Saving build…" });
const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, { const res = await api.post("/api/v1/builds/me", {
method: "POST", title,
headers: { "Content-Type": "application/json" }, description: saveAsDesc?.trim() || null,
credentials: "include", isPublic: false,
body: JSON.stringify({ items,
title,
description: saveAsDesc?.trim() || null,
isPublic: false,
items,
}),
}); });
if (!res.ok) { if (!res.ok) {
+260 -537
View File
@@ -1,305 +1,119 @@
// app/vault/[uuid]/edit/page.tsx
"use client"; "use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
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 = { type BuildDto = {
id: string;
uuid: string; uuid: string;
title: string; title: string;
description?: string | null; description?: string | null;
isPublic?: boolean | null; isPublic?: boolean | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
items?: BuildItemDto[] | null; items?: Array<{
slot: string;
// Option B: metadata fields on Build (or related BuildProfile) productId: number;
platform?: string | null; productName?: string | null;
caliber?: string | null; bestPrice?: number | string | null; // backend may serialize BigDecimal as string
// 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<PlatformOption, "">,
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) { function fmt(d?: string | null) {
if (!d) return "—"; if (!d) return "—";
const dt = new Date(d); const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d; return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
return dt.toLocaleString();
} }
function isUuid(v: string) { export default function VaultBuildEditPage() {
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( const { uuid } = useParams<{ uuid: string }>();
v
);
}
export default function EditBuildPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const api = useApi();
const { user, loading, getAuthHeaders } = useAuth(); const { token, loading: authLoading } = 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<string | null>(null);
const [build, setBuild] = useState<BuildDto | null>(null); const [build, setBuild] = useState<BuildDto | null>(null);
const [form, setForm] = useState({
title: "",
description: "",
isPublic: false,
});
// Editable submission/profile fields const [busy, setBusy] = useState(false);
const [profile, setProfile] = useState<{ const [error, setError] = useState<string | null>(null);
title: string; const [savedMsg, setSavedMsg] = useState<string | null>(null);
description: string;
isPublic: boolean;
platform: PlatformOption; const authed = !!token;
caliber: CaliberOption;
buildClass: BuildClassOption;
tagsText: string; // comma-separated for MVP // Load build once auth is ready + token exists
coverImageUrl: string;
} | null>(null);
// Redirect to login if not authed
useEffect(() => { useEffect(() => {
if (!loading && !authed) router.replace("/login"); if (!uuid) return;
}, [loading, authed, router]); if (authLoading) return;
// Load build if (!authed) {
useEffect(() => { setError("Please log in to edit builds.");
if (loading || !authed) return;
if (!uuid || !isUuid(uuid)) {
setError("Invalid build id.");
return; return;
} }
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setBusy(true);
setError(null);
try { try {
// NOTE: ownership / public checks happen server-side setError(null);
const res = await fetch( const res = await api.get(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`, `/api/v1/builds/me/${encodeURIComponent(uuid)}`
{
credentials: "include",
}
); );
if (!res.ok) { if (!res.ok) {
const txt = await res.text().catch(() => ""); 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; if (cancelled) return;
setBuild(data);
setBuild(dto); setForm({
title: data.title ?? "",
const normalizedPlatform: PlatformOption = PLATFORM_OPTIONS.includes( description: data.description ?? "",
(dto.platform ?? "") as any isPublic: Boolean(data.isPublic),
)
? (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) { } catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load build"); if (!cancelled) setError(e?.message ?? "Failed to load build");
} finally {
if (!cancelled) setBusy(false);
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [loading, authed, uuid, headers]); }, [uuid, authLoading, authed, api]);
// Derived caliber options based on selected platform const dirty = useMemo(() => {
const caliberOptions = useMemo(() => { if (!build) return false;
if (!profile?.platform) return [];
return ( return (
CALIBERS_BY_PLATFORM[profile.platform as Exclude<PlatformOption, "">] ?? 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 async function onSave() {
useEffect(() => { if (!uuid) return;
if (!profile) return; setBusy(true);
if (!profile.platform) return; setSavedMsg(null);
const allowed =
CALIBERS_BY_PLATFORM[profile.platform as Exclude<PlatformOption, "">] ??
[];
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); setError(null);
try { try {
const payload = { const payload = {
title, title: form.title.trim() || "Untitled Build",
description: profile.description?.trim() || null, description: form.description.trim() || null,
isPublic: !!profile.isPublic, isPublic: form.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( const res = await api.put(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuid)}`, `/api/v1/builds/me/${encodeURIComponent(uuid)}`,
{ payload
method: "PUT",
headers: {
"Content-Type": "application/json",
...headers,
},
credentials: "include",
body: JSON.stringify(payload),
}
); );
if (!res.ok) { if (!res.ok) {
@@ -307,341 +121,250 @@ export default function EditBuildPage() {
throw new Error(txt || `Save failed (${res.status})`); throw new Error(txt || `Save failed (${res.status})`);
} }
const updated = (await res.json().catch(() => null)) as BuildDto | null; const updated = (await res.json()) as BuildDto;
if (updated?.uuid) setBuild(updated); setBuild(updated);
setForm({
router.replace("/vault"); title: updated.title ?? "",
description: updated.description ?? "",
isPublic: Boolean(updated.isPublic),
});
setSavedMsg("Saved.");
setTimeout(() => setSavedMsg(null), 2000);
} catch (e: any) { } catch (e: any) {
setError(e?.message || "Save failed"); setError(e?.message ?? "Save failed");
} finally { } finally {
setSaving(false); setBusy(false);
} }
};
if (loading || busy) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
} }
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 ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <main className="mx-auto max-w-3xl p-6">
<div className="mb-6 flex items-start justify-between gap-4"> <div className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0"> <div>
<h1 className="text-2xl font-semibold truncate">Edit Build</h1> <h1 className="text-lg font-semibold">Edit Build</h1>
<div className="mt-1 text-xs opacity-70 font-mono truncate"> <div className="mt-1 text-xs text-zinc-500">
{uuid} UUID: <span className="font-mono">{uuid}</span>
</div>
<div className="mt-1 text-xs opacity-60">
Updated: {fmt(build?.updatedAt)}
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<Link <Link
prefetch={false} href="/vault"
href={`/builder?load=${encodeURIComponent(uuid)}`} className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
> >
Open in Builder
</Link>
<Link href="/vault" className="text-sm opacity-80 hover:underline">
Vault Vault
</Link> </Link>
<button
className="rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
disabled={!uuid}
onClick={() =>
router.push(`/builder?load=${encodeURIComponent(uuid)}`)
}
title="Open this build in the builder to swap parts"
>
Open in Builder
</button>
</div> </div>
</div> </div>
{error && ( {authLoading && <div className="text-sm text-zinc-400">Loading</div>}
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{!authLoading && error && (
<pre className="mb-4 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
{error} {error}
</div> </pre>
)} )}
{!profile ? ( {!authLoading && build && (
<div className="rounded-xl border border-white/10 bg-white/5 p-4 text-sm opacity-70"> <div className="space-y-4">
No build loaded. {/* meta */}
</div> <div className="rounded-xl border border-white/10 bg-white/5 p-4">
) : ( <div className="flex flex-wrap items-center justify-between gap-2">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div className="text-xs text-zinc-400">
{/* Left: form */} Created: {fmt(build.createdAt)} Updated:{" "}
<div className="lg:col-span-2 rounded-xl border border-white/10 bg-white/5 p-4"> {fmt(build.updatedAt)}
<div className="mb-4">
<div className="text-sm font-medium">Submission Details</div>
<div className="text-xs opacity-70">
Controlled fields keep the community feed clean + filterable.
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Title */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-white/70">
Title
</label>
<input
value={profile.title}
onChange={(e) =>
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"'
/>
</div> </div>
{/* Platform */} <span
<div> className={`rounded-full border px-2 py-0.5 text-[11px] ${
<label className="block text-xs font-medium text-white/70"> form.isPublic
Platform ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
</label> : "border-white/10 bg-white/5 text-white/70"
<select
value={profile.platform}
onChange={(e) =>
setProfile((p) =>
p
? {
...p,
platform: e.target.value as PlatformOption,
}
: 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"
>
<option value="">Select</option>
{PLATFORM_OPTIONS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<div className="mt-1 text-xs text-white/40">
Controlled list (prevents messy feed data).
</div>
</div>
{/* Caliber */}
<div>
<label className="block text-xs font-medium text-white/70">
Caliber
</label>
<select
value={profile.caliber}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, caliber: e.target.value as CaliberOption } : p
)
}
disabled={!profile.platform}
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 ${
!profile.platform ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<option value="">
{profile.platform ? "Select…" : "Select platform first…"}
</option>
{caliberOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<div className="mt-1 text-xs text-white/40">
Options are tied to platform. Update{" "}
<span className="font-mono">CALIBERS_BY_PLATFORM</span> to add
more.
</div>
</div>
{/* Build Class */}
<div>
<label className="block text-xs font-medium text-white/70">
Build Class
</label>
<select
value={profile.buildClass}
onChange={(e) =>
setProfile((p) =>
p
? {
...p,
buildClass: e.target.value as BuildClassOption,
}
: 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"
>
<option value="">Select</option>
{BUILD_CLASS_OPTIONS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
{/* Tags */}
<div>
<label className="block text-xs font-medium text-white/70">
Tags
</label>
<input
value={profile.tagsText}
onChange={(e) =>
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"
/>
<div className="mt-1 text-xs text-white/40">
Comma-separated for MVP.
</div>
</div>
{/* Cover image URL */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-white/70">
Cover Image URL
</label>
<input
value={profile.coverImageUrl}
onChange={(e) =>
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://…"
/>
<div className="mt-1 text-xs text-white/40">
Later well replace this with uploads.
</div>
</div>
{/* Description */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-white/70">
Description
</label>
<textarea
value={profile.description}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, description: e.target.value } : p
)
}
rows={5}
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="Whats the goal of this build? Any notes, constraints, lessons learned…"
/>
</div>
{/* Public toggle */}
<div className="md:col-span-2 flex items-center justify-between rounded-md border border-white/10 bg-black/20 px-3 py-2">
<div>
<div className="text-sm font-medium">Make Public</div>
<div className="text-xs opacity-70">
When enabled, this build can appear in the community feed
later.
</div>
</div>
<input
type="checkbox"
checked={!!profile.isPublic}
onChange={(e) =>
setProfile((p) =>
p ? { ...p, isPublic: e.target.checked } : p
)
}
className="h-5 w-5 accent-amber-400"
/>
</div>
</div>
{/* Actions */}
<div className="mt-6 flex items-center justify-end gap-3">
<Link
href="/vault"
className="text-sm opacity-80 hover:underline"
>
Cancel
</Link>
<button
type="button"
onClick={save}
disabled={saving}
className={`rounded-md px-4 py-2 text-sm font-semibold ${
saving
? "bg-white/10 text-white/60 cursor-not-allowed"
: "bg-amber-400 text-black hover:bg-amber-300"
}`} }`}
> >
{saving ? "Saving…" : "Save Changes"} {form.isPublic ? "Published" : "Draft"}
</button> </span>
</div> </div>
</div> </div>
{/* Right: preview */} {/* parts */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4"> <div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-3"> <div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium">Preview</div> <div className="text-sm font-semibold">Parts in this build</div>
<div className="text-xs opacity-70"> <div className="text-xs text-zinc-400">
This is roughly what the community card will show. {build.items?.length ? `${build.items.length} items` : "—"}
</div> </div>
</div> </div>
<div className="rounded-lg border border-white/10 bg-black/30 p-3"> {!build.items?.length ? (
<div className="text-sm font-semibold truncate"> <div className="text-sm opacity-70">No parts saved yet.</div>
{profile.title || "Untitled Build"} ) : (
<div className="space-y-4">
{Object.entries(
build.items.reduce<Record<string, typeof build.items>>(
(acc, it) => {
(acc[it.slot] ||= []).push(it);
return acc;
},
{}
)
).map(([slot, items]) => (
<div key={slot}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-400">
{slot}
</div>
<div className="rounded-lg border border-white/10 bg-black/20">
{items.map((it, idx) => (
<div
key={`${it.slot}-${it.productId}-${idx}`}
className="flex items-center justify-between gap-4 px-3 py-2 text-sm border-b border-white/5 last:border-b-0"
>
<div className="min-w-0">
<div className="truncate text-zinc-100">
{it.productName ?? `Product #${it.productId}`}
</div>
<div className="text-xs text-zinc-400">
ID:{" "}
<span className="font-mono">{it.productId}</span>
</div>
</div>
<div className="shrink-0 text-right">
<div className="font-semibold">
{typeof it.bestPrice === "number"
? `$${it.bestPrice.toFixed(2)}`
: it.bestPrice
? `$${Number(it.bestPrice).toFixed(2)}`
: "—"}
</div>
<div className="text-[11px] text-zinc-500">
Best price
</div>
</div>
</div>
))}
</div>
</div>
))}
</div> </div>
)}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs"> {/* form */}
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1"> <div className="rounded-xl border border-white/10 bg-white/5 p-4">
{profile.platform || "—"} <label className="block text-xs font-medium text-zinc-300">
</span> Title
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1"> </label>
{profile.caliber || "—"} <input
</span> value={form.title}
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1"> onChange={(e) =>
{profile.buildClass || "—"} setForm((f) => ({ ...f, title: e.target.value }))
</span> }
</div> className="mt-2 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
placeholder="e.g. Suppressed MK2"
maxLength={120}
/>
{profile.tagsText?.trim() ? ( <label className="mt-4 block text-xs font-medium text-zinc-300">
<div className="mt-2 text-xs opacity-80"> Notes / Description
Tags:{" "} </label>
{profile.tagsText <textarea
.split(",") value={form.description}
.map((t) => t.trim()) onChange={(e) =>
.filter(Boolean) setForm((f) => ({ ...f, description: e.target.value }))
.slice(0, 6) }
.join(", ")} className="mt-2 min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
</div> placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
) : null} maxLength={2000}
/>
{profile.description?.trim() ? ( <div className="mt-4 flex items-center justify-between gap-3">
<div className="mt-3 text-xs opacity-80 line-clamp-5"> <label className="flex items-center gap-2 text-sm">
{profile.description} <input
</div> type="checkbox"
) : ( checked={form.isPublic}
<div className="mt-3 text-xs opacity-50"> onChange={(e) =>
Add a description to help people understand the why. setForm((f) => ({ ...f, isPublic: e.target.checked }))
</div> }
)} />
<span className="text-zinc-200">Publish to community</span>
</label>
<div className="mt-3 text-[11px] opacity-60"> <div className="flex items-center gap-2">
Build UUID: <span className="font-mono">{uuid}</span> {savedMsg && (
<span className="text-xs text-emerald-300">{savedMsg}</span>
)}
<button
onClick={onSave}
disabled={busy || !dirty}
className="rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black hover:bg-zinc-200 disabled:opacity-50"
>
{busy ? "Working…" : dirty ? "Save" : "Saved"}
</button>
</div> </div>
</div> </div>
</div> </div>
{/* danger zone */}
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
<div className="text-sm font-semibold text-red-200">
Danger zone
</div>
<div className="mt-1 text-xs text-red-200/70">
Deleting a build is permanent.
</div>
<button
onClick={onDelete}
disabled={busy}
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-100 hover:bg-red-500/15 disabled:opacity-50"
>
Delete build
</button>
</div>
</div> </div>
)} )}
</div> </main>
); );
} }
+27 -26
View File
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = { type BuildDto = {
id: string; // internal numeric id (not used by UI) id: string; // internal numeric id (not used by UI)
@@ -35,26 +36,36 @@ function fmt(d?: string | null) {
export default function VaultPage() { export default function VaultPage() {
const router = useRouter(); const router = useRouter();
const { user, loading, getAuthHeaders } = useAuth(); const api = useApi();
// NOTE:
// - `loading` here is AuthContext hydration, not network.
// - `token` is the real gate for /me endpoints.
const { user, token, loading: authLoading } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]); const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const authed = !!user; const authed = !!user && !!token;
// Redirect if not logged in // Redirect if not logged in (after auth hydrates)
useEffect(() => { useEffect(() => {
if (!loading && !authed) { if (!authLoading && !authed) {
router.replace("/login"); router.replace("/login");
} }
}, [loading, authed, router]); }, [authLoading, authed, router]);
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]); // When auth identity changes, clear stale list to avoid “ghost builds”
useEffect(() => {
if (authLoading) return;
setBuilds([]);
setError(null);
}, [authLoading, user?.uuid, token]);
// Fetch user builds // Fetch user builds
useEffect(() => { useEffect(() => {
if (loading || !authed) return; if (authLoading || !authed) return;
let cancelled = false; let cancelled = false;
@@ -63,11 +74,9 @@ export default function VaultPage() {
setError(null); setError(null);
try { try {
const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, { // IMPORTANT:
method: "GET", // Use api wrapper so Authorization: Bearer <token> is consistently applied.
headers, const res = await api.get("/api/v1/builds/me");
credentials: "include",
});
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
@@ -86,9 +95,9 @@ export default function VaultPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [loading, authed, headers]); }, [authLoading, authed, api]);
if (loading) { if (authLoading) {
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div> <div className="text-sm opacity-70">Loading</div>
@@ -142,7 +151,7 @@ export default function VaultPage() {
{builds.length === 0 ? ( {builds.length === 0 ? (
<div className="p-4 text-sm opacity-70"> <div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save Build. No builds yet. Create one in the builder and hit Save As
</div> </div>
) : ( ) : (
<ul className="divide-y divide-white/10"> <ul className="divide-y divide-white/10">
@@ -186,7 +195,7 @@ export default function VaultPage() {
{!valid && ( {!valid && (
<span <span
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200" className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
title="This build record has an invalid UUID. The backend returned a bad identifier." title="This build record has an invalid UUID."
> >
Invalid UUID Invalid UUID
</span> </span>
@@ -215,11 +224,7 @@ export default function VaultPage() {
? "border-white/10 bg-white/5 hover:bg-white/10" ? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none" : "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`} }`}
title={ title={valid ? "Load this build into the builder" : "Invalid build id"}
valid
? "Load this build into the builder"
: "Invalid build id"
}
> >
Open Open
</Link> </Link>
@@ -233,11 +238,7 @@ export default function VaultPage() {
? "border-white/10 bg-white/5 hover:bg-white/10" ? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none" : "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`} }`}
title={ title={valid ? "Edit details / publish later" : "Invalid build id"}
valid
? "Add details + submit/publish later"
: "Invalid build id"
}
> >
Edit Edit
</Link> </Link>
+3 -12
View File
@@ -13,6 +13,7 @@ const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type AuthUser = { type AuthUser = {
uuid: string;
email: string; email: string;
displayName?: string | null; displayName?: string | null;
role: string; role: string;
@@ -35,14 +36,7 @@ type AuthContextValue = {
* Used for non-password auth flows (ex: magic link). * Used for non-password auth flows (ex: magic link).
* Persists session exactly like login/register. * Persists session exactly like login/register.
*/ */
setSession: ( setSession: (token: string, user: NonNullable<AuthUser>) => void;
token: string,
user: {
email: string;
displayName: string | null;
role: string;
}
) => void;
}; };
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
@@ -95,10 +89,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const setSession = useCallback( const setSession = useCallback(
( (nextToken: string, nextUser: NonNullable<AuthUser>) => {
nextToken: string,
nextUser: { email: string; displayName: string | null; role: string }
) => {
setToken(nextToken); setToken(nextToken);
setUser(nextUser); setUser(nextUser);
persistAuth(nextToken, nextUser); persistAuth(nextToken, nextUser);
+55 -18
View File
@@ -1,26 +1,63 @@
"use client";
import { useMemo } from "react";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type JsonValue = any;
export function useApi() { export function useApi() {
const { token } = useAuth(); const { token } = useAuth();
async function get(path: string) { return useMemo(() => {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, { function authHeaders(extra?: HeadersInit): HeadersInit {
headers: { const base: Record<string, string> = {};
Authorization: token ? `Bearer ${token}` : "", if (token) base.Authorization = `Bearer ${token}`;
}, return { ...base, ...(extra as any) };
}); }
}
async function post(path: string, body: any) { async function get(path: string, init?: RequestInit) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, { return fetch(`${API_BASE_URL}${path}`, {
method: "POST", ...init,
headers: { headers: authHeaders(init?.headers),
"Content-Type": "application/json", });
Authorization: token ? `Bearer ${token}` : "", }
},
body: JSON.stringify(body),
});
}
return { get, post }; async function post(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "POST",
...init,
headers: authHeaders({
"Content-Type": "application/json",
...(init?.headers as any),
}),
body: JSON.stringify(body),
});
}
async function put(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "PUT",
...init,
headers: authHeaders({
"Content-Type": "application/json",
...(init?.headers as any),
}),
body: JSON.stringify(body),
});
}
async function del(path: string, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "DELETE",
...init,
headers: authHeaders(init?.headers),
});
}
// ✅ stable reference unless token changes
return { get, post, put, del };
}, [token]);
} }