19 Commits

Author SHA1 Message Date
sean 8d7809ccf1 added stuff to builds/details 2025-12-26 21:04:33 -05:00
sean e202d811ed new builds/id page 2025-12-26 20:53:04 -05:00
sean c8bf032f7a set admin behind a cookie and check role on profile 2025-12-26 17:19:11 -05:00
sean 5401f6464d disable beta sign up email blasts 2025-12-24 08:34:39 -05:00
sean 57034eefc3 added enrichment ui and functionality 2025-12-23 11:18:34 -05:00
sean bb8ddb6823 small product grid cleanup 2025-12-23 06:17:34 -05:00
dstrawsb 70fac2857e fixing titles and stuff 2025-12-22 23:39:12 -05:00
sean c01aabb003 cleaned up product details page. added a link on builder to the selected product details page 2025-12-22 17:31:04 -05:00
sean 061d737c6c fixing platform switcher bugs 2025-12-22 09:03:32 -05:00
sean 34e915f904 fixed layouts, routing and the platform switcher. 2025-12-22 08:36:19 -05:00
sean b1a8dae8ed added favicon 2025-12-21 07:52:16 -05:00
sean 13edcdce69 fix possible mem leaks on photos 2025-12-21 07:40:45 -05:00
sean 856b99b933 allow users to upload build images. No backend, just local previews. need to build backend 2025-12-21 07:39:32 -05:00
sean 008936ff98 fixing vault. allow users to delete their builds or update 2025-12-21 07:20:22 -05:00
sean 47d5cf239d magic link support and forgot password 2025-12-20 18:58:25 -05:00
sean 8d9013d0dd beta sign up sends an email with a magic token. then a separate email to login. 2025-12-20 17:15:10 -05:00
sean c06696e66d fixed an vault edit error. 2025-12-20 12:24:40 -05:00
sean 54ea3243fd Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2025-12-20 10:01:22 -05:00
sean 61935982b3 new pages for user buils (vault), edit saved builds, and the community build page. 2025-12-20 10:01:07 -05:00
49 changed files with 4328 additions and 1148 deletions
+14
View File
@@ -0,0 +1,14 @@
// app/(account)/AccountChrome.tsx
"use client";
import TopNav from "@/components/TopNav";
import BuilderNav from "@/components/BuilderNav";
export default function AccountChrome() {
return (
<>
<TopNav />
<BuilderNav />
</>
);
}
+403 -8
View File
@@ -1,10 +1,58 @@
// app/(account)/account/page.tsx // app/(account)/account/page.tsx
"use client"; "use client";
import { useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export default function AccountPage() { export default function AccountPage() {
const { user, loading } = useAuth(); const { user, loading, token, setSession, logout } = useAuth();
const searchParams = useSearchParams();
const router = useRouter();
const welcome = searchParams.get("welcome") === "1";
const needsDisplayName = useMemo(() => {
return !user?.displayName || user.displayName.trim().length === 0;
}, [user?.displayName]);
// Only show welcome panel if they actually need setup
const showWelcomeCard = welcome && needsDisplayName;
// ----------------------------
// Display Name state (shared)
// ----------------------------
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
// Inline profile editor
const [editingName, setEditingName] = useState(false);
// ----------------------------
// Password state
// ----------------------------
const [showPassword, setShowPassword] = useState(false);
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
const canSaveName = useMemo(() => {
return displayName.trim().length > 0 && !saving;
}, [displayName, saving]);
const pwMismatch = useMemo(
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
[pw1, pw2]
);
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
const canSavePassword = useMemo(() => {
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
}, [pwSaving, pw1, pw2]);
if (loading) return <div className="text-sm opacity-70">Loading</div>; if (loading) return <div className="text-sm opacity-70">Loading</div>;
@@ -15,30 +63,377 @@ export default function AccountPage() {
</div> </div>
); );
} }
const authedUser = user; // user is non-null from here down
async function saveDisplayName() {
setSaving(true);
setMsg(null);
try {
const nextName = displayName.trim();
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ displayName: nextName }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Failed to update profile");
}
const updated = await res.json().catch(() => null);
const nextUser = {
email: authedUser.email,
displayName: updated?.displayName ?? nextName,
role: authedUser.role,
};
if (token) setSession(token, nextUser);
setMsg("Saved ✅");
setEditingName(false);
// If we came from the welcome flow, clear the param
if (welcome) router.replace("/account");
} catch (e: any) {
setMsg(e?.message || "Couldnt save. Try again.");
} finally {
setSaving(false);
}
}
async function savePassword() {
setPwMsg(null);
if (pw1.length < 8) {
setPwMsg("Password must be at least 8 characters.");
return;
}
if (pw1 !== pw2) {
setPwMsg("Passwords dont match.");
return;
}
setPwSaving(true);
try {
const res = await fetch(`${API_BASE_URL}/api/users/me/password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ password: pw1 }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Failed to set password");
}
setPwMsg("Password set ✅ You can now use email + password login.");
setPw1("");
setPw2("");
setShowPassword(false);
if (welcome) router.replace("/account");
} catch (e: any) {
setPwMsg(e?.message || "Couldnt set password. Try again.");
} finally {
setPwSaving(false);
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{/* Welcome / Setup card (only if needed) */}
{showWelcomeCard && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
<div>
<h2 className="text-base font-semibold">Welcome to the beta 👋</h2>
<p className="text-sm text-zinc-300/80">
Quick setup and youre ready to cook.
</p>
</div>
{/* Display name */}
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-200">
Display name
</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<div className="flex items-center gap-3">
<button
onClick={saveDisplayName}
disabled={!canSaveName}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
</button>
{msg && (
<span
className={`text-xs ${
msg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{msg}
</span>
)}
</div>
</div>
{/* Password */}
<div className="pt-2 border-t border-white/10">
<p className="text-xs text-zinc-300/80">
Want password login later? Optional (but recommended).
</p>
{!showPassword ? (
<button
onClick={() => setShowPassword(true)}
className="mt-2 text-xs underline opacity-80 hover:opacity-100"
>
Set a password
</button>
) : (
<div className="mt-3 space-y-2">
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="New password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<input
type="password"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Confirm password"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort
? "Password must be at least 8 characters."
: "Passwords dont match."}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
<button
onClick={() => {
setShowPassword(false);
setPw1("");
setPw2("");
setPwMsg(null);
}}
className="text-xs opacity-80 hover:opacity-100"
>
Cancel
</button>
</div>
{pwMsg && (
<div
className={`text-xs ${
pwMsg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{pwMsg}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Profile header */}
<div> <div>
<h2 className="text-lg font-semibold">Profile</h2> <h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">Basic account info.</p>
Basic account info (well expand this soon).
</p>
</div> </div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-2"> {/* Profile card */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="text-sm"> <div className="text-sm">
<span className="opacity-70">Email:</span>{" "} <span className="opacity-70">Email:</span>{" "}
<span className="font-medium">{user.email}</span> <span className="font-medium">{user.email}</span>
</div> </div>
{/* Display name (inline editable) */}
<div className="text-sm"> <div className="text-sm">
<span className="opacity-70">Display name:</span>{" "} <span className="opacity-70">Display name:</span>{" "}
<span className="font-medium">{user.displayName || "—"}</span> {!editingName ? (
<>
<span className="font-medium">{user.displayName || "—"}</span>
<button
onClick={() => {
setEditingName(true);
setMsg(null);
setDisplayName(user.displayName ?? "");
}}
className="ml-3 text-xs underline opacity-70 hover:opacity-100"
>
Edit
</button>
</>
) : (
<div className="mt-2 space-y-2">
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your display name"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<div className="flex items-center gap-3">
<button
onClick={saveDisplayName}
disabled={!canSaveName}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
</button>
<button
onClick={() => {
setEditingName(false);
setDisplayName(user.displayName ?? "");
setMsg(null);
}}
className="text-xs opacity-80 hover:opacity-100"
>
Cancel
</button>
{msg && (
<span
className={`text-xs ${
msg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{msg}
</span>
)}
</div>
</div>
)}
</div> </div>
<div className="text-sm"> <div className="text-sm">
<span className="opacity-70">Role:</span>{" "} <span className="opacity-70">Role:</span>{" "}
<span className="font-medium">{user.role}</span> <span className="font-medium">{user.role}</span>
</div> </div>
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
<button
onClick={logout}
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
>
Log out
</button>
{/* Optional: quick link to set password later */}
<button
onClick={() => {
setShowPassword(true);
// Scroll users attention to welcome panel area if it exists,
// otherwise just open the password UI in their mind.
// (Kept simple: you can add a dedicated card later if you want)
setPwMsg(null);
}}
className="text-xs underline opacity-70 hover:opacity-100"
>
Set / change password
</button>
</div>
{/* If user clicks "Set/change password" outside welcome flow, show it here */}
{!showWelcomeCard && showPassword && (
<div className="pt-4 border-t border-white/10 space-y-2">
<div className="text-sm font-medium">Set password</div>
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="New password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<input
type="password"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Confirm password"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort
? "Password must be at least 8 characters."
: "Passwords dont match."}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
<button
onClick={() => {
setShowPassword(false);
setPw1("");
setPw2("");
setPwMsg(null);
}}
className="text-xs opacity-80 hover:opacity-100"
>
Cancel
</button>
</div>
{pwMsg && (
<div
className={`text-xs ${
pwMsg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{pwMsg}
</div>
)}
</div>
)}
</div> </div>
</div> </div>
); );
} }
+22 -21
View File
@@ -1,56 +1,57 @@
// app/(account)/layout.tsx // app/(account)/layout.tsx
import Link from "next/link"; import Link from "next/link";
import AccountChrome from "./AccountChrome";
const nav = [ const nav = [
{ href: "/account", label: "My Account" }, { href: "/account", label: "My Account" },
{ href: "/account/settings", label: "Settings" }, { href: "/account/settings", label: "Settings" },
{ href: "/vault", label: "My Vault" },
]; ];
export default function AccountLayout({ export default function AccountLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="mx-auto max-w-6xl px-4 py-6"> <AccountChrome />
<div className="mx-auto w-full max-w-6xl px-4 py-6">
{/* Header */} {/* Header */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 className="text-2xl font-semibold">Account</h1> <div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
<p className="text-sm opacity-70"> Account
</div>
<h1 className="mt-1 text-2xl font-semibold text-white/90">
Settings & Profile
</h1>
<p className="mt-1 text-sm text-white/60">
Profile, password, and account settings. Profile, password, and account settings.
</p> </p>
</div> </div>
<Link href="/builder" className="text-sm opacity-80 hover:underline"> <Link
href="/builder"
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
>
Back to Builder Back to Builder
</Link> </Link>
</div> </div>
<div className="grid gap-6 md:grid-cols-[240px_1fr]"> <div className="grid gap-6 lg:grid-cols-12">
<aside className="rounded-xl border border-white/10 bg-white/5 p-3"> <aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
{nav.map((item) => ( {nav.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className="rounded-lg px-3 py-2 text-sm opacity-80 hover:bg-white/10 hover:opacity-100" className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
> >
{item.label} {item.label}
</Link> </Link>
))} ))}
<div className="my-2 border-t border-white/10" />
<Link
href="/vault"
className="rounded-lg px-3 py-2 text-sm opacity-80 hover:bg-white/10 hover:opacity-100"
>
My Vault
</Link>
</nav> </nav>
</aside> </aside>
<main className="rounded-xl border border-white/10 bg-white/5 p-4"> <main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
{children} {children}
</main> </main>
</div> </div>
@@ -1,14 +1,36 @@
// app/(builder)/builder/page.tsx // app/(builder)/builder/page.tsx
"use client"; "use client";
/**
* Battl Builder - Main builder page
*
* Key flows:
* 1) Build selections are stored locally (localStorage) so users can come back.
* 2) "Save As…" creates a NEW saved build in the user's Vault (server-side).
* 3) Vault -> Builder loads builds via ?load=<uuid> (also supports legacy ?build=<uuid>).
*
* 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 { 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";
@@ -31,6 +53,11 @@ type GunbuilderProductFromApi = {
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const isUuid = (v: string) =>
/^[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
);
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -114,10 +141,29 @@ 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)
// -----------------------------
const [saveAsOpen, setSaveAsOpen] = useState(false);
const [saveAsTitle, setSaveAsTitle] = useState("");
const [saveAsDesc, setSaveAsDesc] = useState("");
const [saveAsSaving, setSaveAsSaving] = useState(false); // prevents double-submit
// -----------------------------
// Parts data state
// -----------------------------
const [parts, setParts] = useState<Part[]>([]); const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// -----------------------------
// Platform state (AR-15/AR-10/AR-9)
// -----------------------------
const isValidPlatform = ( const isValidPlatform = (
value: string | null value: string | null
): value is (typeof PLATFORMS)[number] => ): value is (typeof PLATFORMS)[number] =>
@@ -129,6 +175,9 @@ export default function GunbuilderPage() {
return isValidPlatform(initial) ? initial : "AR-15"; return isValidPlatform(initial) ? initial : "AR-15";
}); });
// -----------------------------
// Build state (categoryId -> productId), persisted locally
// -----------------------------
const [build, setBuild] = useState<BuildState>(() => { const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@@ -143,6 +192,20 @@ export default function GunbuilderPage() {
return {}; return {};
}); });
// -----------------------------
// Toast notifications (save success / errors)
// -----------------------------
type Toast = { type: "success" | "error" | "info"; message: string };
const [toast, setToast] = useState<Toast | null>(null);
const showToast = useCallback((t: Toast) => {
setToast(t);
window.setTimeout(() => setToast(null), 3500);
}, []);
// -----------------------------
// Misc UI state
// -----------------------------
const [shareStatus, setShareStatus] = useState<string | null>(null); const [shareStatus, setShareStatus] = useState<string | null>(null);
const [shareUrl, setShareUrl] = useState<string>(""); const [shareUrl, setShareUrl] = useState<string>("");
@@ -153,7 +216,9 @@ export default function GunbuilderPage() {
// ✅ Guard to prevent infinite loops when processing ?select / ?remove // ✅ Guard to prevent infinite loops when processing ?select / ?remove
const processedActionKeyRef = useRef<string>(""); const processedActionKeyRef = useRef<string>("");
// ---------- Derived ---------- // -----------------------------
// Derived collections
// -----------------------------
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => { const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>; const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) { for (const category of CATEGORIES) {
@@ -177,8 +242,26 @@ export default function GunbuilderPage() {
}); });
}, [build, parts]); }, [build, parts]);
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
() =>
Object.fromEntries(
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
])
) as Record<CategoryId, unknown>,
[build]
);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts]
);
// -----------------------------
// Role -> Category resolver // Role -> Category resolver
// NOTE: defensive while backend roles/mappings evolve // NOTE: defensive while backend roles/mappings evolve
// -----------------------------
const resolveCategoryId = (normalizedRole: string): CategoryId | null => { const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
if (normalizedRole === "upper-receiver") return "upper-receiver"; if (normalizedRole === "upper-receiver") return "upper-receiver";
if (normalizedRole.includes("complete-upper")) return "complete-upper"; if (normalizedRole.includes("complete-upper")) return "complete-upper";
@@ -271,22 +354,9 @@ export default function GunbuilderPage() {
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null; return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
}; };
const selectedByCategory: Record<CategoryId, unknown> = useMemo( // -----------------------------
() => // Selection handler (with hint warnings)
Object.fromEntries( // -----------------------------
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
])
) as Record<CategoryId, unknown>,
[build]
);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts]
);
const handleSelectPart = useCallback( const handleSelectPart = useCallback(
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => { (categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
const warn = (msg: string) => { const warn = (msg: string) => {
@@ -305,7 +375,9 @@ export default function GunbuilderPage() {
[build] [build]
); );
// ---------- Fetch products ---------- // -----------------------------
// Fetch products (scoped + universal merge)
// -----------------------------
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -317,7 +389,6 @@ export default function GunbuilderPage() {
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent( const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
platform platform
)}`; )}`;
const universalUrl = `${API_BASE_URL}/api/v1/products`; const universalUrl = `${API_BASE_URL}/api/v1/products`;
const [scopedRes, universalRes] = await Promise.all([ const [scopedRes, universalRes] = await Promise.all([
@@ -350,6 +421,7 @@ export default function GunbuilderPage() {
const categoryId = resolveCategoryId(normalizedRole); const categoryId = resolveCategoryId(normalizedRole);
if (!categoryId) return null; if (!categoryId) return null;
// Only accept universal fetch results for "universal" categories
if ( if (
data === universalData && data === universalData &&
!UNIVERSAL_CATEGORIES.has(categoryId) !UNIVERSAL_CATEGORIES.has(categoryId)
@@ -377,6 +449,7 @@ export default function GunbuilderPage() {
const scopedParts = normalize(scopedData); const scopedParts = normalize(scopedData);
const universalParts = normalize(universalData); const universalParts = normalize(universalData);
// Merge (avoid duplicates across calls)
const seen = new Set<string>(); const seen = new Set<string>();
const merged: Part[] = []; const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) { for (const p of [...scopedParts, ...universalParts]) {
@@ -399,7 +472,9 @@ export default function GunbuilderPage() {
return () => controller.abort(); return () => controller.abort();
}, [platform]); }, [platform]);
// ✅ Persist build state whenever it changes // -----------------------------
// Persist build to localStorage
// -----------------------------
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
@@ -409,7 +484,9 @@ export default function GunbuilderPage() {
} }
}, [build]); }, [build]);
// When the platform changes, clear ONLY platform-scoped selections. // -----------------------------
// When platform changes, clear ONLY platform-scoped selections
// -----------------------------
useEffect(() => { useEffect(() => {
const prev = lastPlatformRef.current; const prev = lastPlatformRef.current;
lastPlatformRef.current = platform; lastPlatformRef.current = platform;
@@ -433,21 +510,43 @@ export default function GunbuilderPage() {
}); });
}, [platform]); }, [platform]);
// ✅ Load a saved build from Vault via ?load=<uuid> // -----------------------------
// Load a saved build from Vault via ?load=<uuid> OR legacy ?build=<uuid>
// -----------------------------
useEffect(() => { useEffect(() => {
const loadUuid = searchParams.get("load"); const loadUuid = searchParams.get("load");
if (!loadUuid) return; const buildParam = searchParams.get("build");
const uuidToLoad =
(loadUuid && isUuid(loadUuid) ? loadUuid : null) ||
(buildParam && isUuid(buildParam) ? buildParam : null);
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: your backend GET route is /api/v1/products/builds/{uuid} // 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/products/builds/${encodeURIComponent( `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
loadUuid {
)}`, headers: {
{ credentials: "include" } "Content-Type": "application/json",
...getAuthHeaders(),
},
}
); );
if (!res.ok) { if (!res.ok) {
@@ -466,10 +565,15 @@ export default function GunbuilderPage() {
setBuild(nextBuild); setBuild(nextBuild);
setShareStatus(`Loaded: ${dto.title}`); setShareStatus(`Loaded: ${dto.title}`);
// Clean URL so reload doesn't re-fetch // Clean URL so refresh doesn't re-load repeatedly
const qp = new URLSearchParams(searchParams.toString()); const qp = new URLSearchParams(searchParams.toString());
qp.delete("load"); qp.delete("load");
router.replace(`/builder?${qp.toString()}`, { scroll: false }); if (buildParam && isUuid(buildParam)) qp.delete("build");
const next = qp.toString();
router.replace(next ? `/builder?${next}` : "/builder", {
scroll: false,
});
} catch (e: any) { } catch (e: any) {
setShareStatus(e?.message ?? "Failed to load build"); setShareStatus(e?.message ?? "Failed to load build");
} finally { } finally {
@@ -478,12 +582,90 @@ 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)
// - Creates a NEW saved build server-side
// - Shows toast so users know it worked
// -----------------------------
const handleSaveAs = async () => {
if (selectedParts.length === 0) {
showToast({ type: "error", message: "Add a few parts before saving." });
return;
}
const title = (saveAsTitle || `${platform} Build`).trim();
if (!title) {
showToast({ type: "error", message: "Title is required." });
return;
}
const items = Object.entries(build)
.filter(([, partId]) => !!partId)
.map(([categoryId, partId]) => ({
productId: Number(partId),
slot: categoryId,
position: 0,
quantity: 1,
}));
try {
setSaveAsSaving(true);
showToast({ type: "info", message: "Saving build…" });
const res = await api.post("/api/v1/builds/me", {
title,
description: saveAsDesc?.trim() || null,
isPublic: false,
items,
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Save failed (${res.status})`);
}
const saved = await res.json(); // BuildDto (expects { uuid, ... })
// UX: copy UUID for quick testing + user confidence
try {
if (saved?.uuid) await navigator.clipboard.writeText(saved.uuid);
} catch {
// ignore clipboard failures
}
showToast({
type: "success",
message: "Saved to Vault ✅ (UUID copied)",
});
// Close modal + reset inputs for next variant
setSaveAsOpen(false);
setSaveAsTitle("");
setSaveAsDesc("");
} catch (e: any) {
showToast({
type: "error",
message: e?.message ?? "Save failed",
});
} finally {
setSaveAsSaving(false);
}
};
// -----------------------------
// Handle URL query parameters: // Handle URL query parameters:
// - ?select=categoryId:partId // - ?select=categoryId:partId
// - ?remove=categoryId // - ?remove=categoryId
// -----------------------------
useEffect(() => { useEffect(() => {
const selectParam = searchParams.get("select"); const selectParam = searchParams.get("select");
const removeParam = searchParams.get("remove"); const removeParam = searchParams.get("remove");
@@ -522,12 +704,11 @@ export default function GunbuilderPage() {
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform; const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
// Keep URL clean/stable
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`; const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const current = `${window.location.pathname}${window.location.search}`; const current = `${window.location.pathname}${window.location.search}`;
if (current !== nextUrl) { if (current !== nextUrl) router.replace(nextUrl, { scroll: false });
router.replace(nextUrl, { scroll: false });
}
} else { } else {
router.replace(nextUrl, { scroll: false }); router.replace(nextUrl, { scroll: false });
} }
@@ -541,7 +722,7 @@ export default function GunbuilderPage() {
} }
}, [searchParams, platform]); }, [searchParams, platform]);
// Build share URL whenever build changes // Build share URL whenever build changes (client-side only)
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -571,53 +752,7 @@ export default function GunbuilderPage() {
[] []
); );
// Handler to Save user build to account // Copy summary text to clipboard
const handleSaveToAccount = async () => {
if (selectedParts.length === 0) {
setShareStatus("Add a few parts before saving.");
return;
}
const title = `${platform} Build`;
const items = Object.entries(build)
.filter(([, partId]) => !!partId)
.map(([categoryId, partId]) => ({
productId: Number(partId),
slot: categoryId,
position: 0,
quantity: 1,
}));
try {
setShareStatus("Saving build…");
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title,
description: null,
isPublic: false,
items,
}),
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`Save failed (${res.status}) ${txt}`);
}
const saved = await res.json(); // BuildDto
setShareStatus(`Saved! Build UUID: ${saved.uuid}`);
} catch (e: any) {
setShareStatus(e?.message ?? "Save failed");
} finally {
window.setTimeout(() => setShareStatus(null), 4500);
}
};
const handleShare = async () => { const handleShare = async () => {
if (selectedParts.length === 0) { if (selectedParts.length === 0) {
setShareStatus("Add a few parts before sharing your build."); setShareStatus("Add a few parts before sharing your build.");
@@ -625,7 +760,7 @@ export default function GunbuilderPage() {
} }
const lines: string[] = []; const lines: string[] = [];
lines.push("B Build"); lines.push("Battl Build");
lines.push(""); lines.push("");
lines.push(`Total: $${totalPrice.toFixed(2)}`); lines.push(`Total: $${totalPrice.toFixed(2)}`);
lines.push(""); lines.push("");
@@ -685,8 +820,8 @@ export default function GunbuilderPage() {
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ await navigator.share({
title: "Shadow Standard — The Armory Build", title: "BATTL — The Builder",
text: "Check out my Shadow Standard Armory build.", text: "Check out my Battl Build.",
url: shareUrl, url: shareUrl,
}); });
setShareStatus("Share dialog opened."); setShareStatus("Share dialog opened.");
@@ -702,6 +837,99 @@ export default function GunbuilderPage() {
return ( return (
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50"> <main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
{/* Toast (top-right) */}
{toast && (
<div className="fixed top-4 right-4 z-50">
<div
className={`rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur
${
toast.type === "success"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-100"
: toast.type === "error"
? "border-red-500/30 bg-red-500/15 text-red-100"
: "border-white/10 bg-white/10 text-white"
}`}
>
{toast.message}
</div>
</div>
)}
{/* Save As… Modal */}
{saveAsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-lg rounded-xl border border-white/10 bg-zinc-950 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold">Save As</div>
<div className="text-xs text-zinc-400">
Create a new saved build variant in your Vault.
</div>
</div>
<button
type="button"
onClick={() => setSaveAsOpen(false)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs hover:bg-white/10"
>
Close
</button>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="block text-xs font-medium text-zinc-300">
Title
</label>
<input
value={saveAsTitle}
onChange={(e) => setSaveAsTitle(e.target.value)}
placeholder={`e.g. "${platform} Mk2 (Suppressed)"`}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-300">
Description (optional)
</label>
<textarea
value={saveAsDesc}
onChange={(e) => setSaveAsDesc(e.target.value)}
rows={3}
placeholder="Notes for future-you…"
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"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setSaveAsOpen(false)}
className="text-sm text-zinc-300 hover:underline"
disabled={saveAsSaving}
>
Cancel
</button>
<button
type="button"
onClick={handleSaveAs}
disabled={saveAsSaving}
className={`rounded-md px-4 py-2 text-sm font-semibold text-black ${
saveAsSaving
? "bg-amber-400/60 cursor-not-allowed"
: "bg-amber-400 hover:bg-amber-300"
}`}
>
{saveAsSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
)}
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10"> <div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */} {/* Header */}
<header className="mb-6"> <header className="mb-6">
@@ -710,7 +938,7 @@ export default function GunbuilderPage() {
BATTL BUILDERS BATTL BUILDERS
</p> </p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight"> <h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
BattlBuilder <span className="text-amber-300">Early Access</span> Builder <span className="text-amber-300">Early Access</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl"> <p className="mt-2 text-sm text-zinc-400 max-w-xl">
Explore components from trusted brands, choose one part per Explore components from trusted brands, choose one part per
@@ -728,6 +956,7 @@ export default function GunbuilderPage() {
const next = e.target.value as (typeof PLATFORMS)[number]; const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next); setPlatform(next);
// Keep URL in sync, and clear transient action params
const qp = new URLSearchParams(searchParams.toString()); const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next); qp.set("platform", next);
qp.delete("select"); qp.delete("select");
@@ -760,6 +989,7 @@ export default function GunbuilderPage() {
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300"> <h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
My Build Breakdown My Build Breakdown
</h2> </h2>
<div> <div>
<div className="text-xs text-zinc-400">Current build total</div> <div className="text-xs text-zinc-400">Current build total</div>
<div className="text-2xl font-semibold text-amber-300"> <div className="text-2xl font-semibold text-amber-300">
@@ -787,10 +1017,12 @@ export default function GunbuilderPage() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
// Reset local build state
setBuild({}); setBuild({});
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
showToast({ type: "info", message: "Build cleared." });
}} }}
disabled={selectedParts.length === 0} disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${ className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
@@ -802,10 +1034,10 @@ export default function GunbuilderPage() {
Clear Build Clear Build
</button> </button>
{/* Save Build */} {/* Save As… triggers modal */}
<button <button
type="button" type="button"
onClick={handleSaveToAccount} onClick={() => setSaveAsOpen(true)}
disabled={selectedParts.length === 0} disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${ className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
selectedParts.length === 0 selectedParts.length === 0
@@ -813,8 +1045,16 @@ export default function GunbuilderPage() {
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300" : "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
}`} }`}
> >
Save Build Save As
</button> </button>
{/* Optional: quick access to Vault */}
<Link
href="/vault"
className="w-full sm:w-auto rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm font-medium text-zinc-200 hover:bg-white/10 text-center"
>
Go to Vault
</Link>
</div> </div>
</div> </div>
@@ -829,6 +1069,7 @@ export default function GunbuilderPage() {
Share this link to let others view your build or bookmark it Share this link to let others view your build or bookmark it
to come back later. to come back later.
</p> </p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center"> <div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1"> <div className="flex-1">
<div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1"> <div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
@@ -841,6 +1082,7 @@ export default function GunbuilderPage() {
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400" className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400"
/> />
</div> </div>
<div className="flex gap-2 md:flex-none md:mt-5"> <div className="flex gap-2 md:flex-none md:mt-5">
<button <button
type="button" type="button"
@@ -849,6 +1091,7 @@ export default function GunbuilderPage() {
> >
Share Share
</button> </button>
<button <button
type="button" type="button"
onClick={handleCopyLink} onClick={handleCopyLink}
@@ -968,12 +1211,14 @@ export default function GunbuilderPage() {
<tbody> <tbody>
{groupCategories.map((category) => { {groupCategories.map((category) => {
const categoryParts = partsByCategory[category.id] ?? []; const categoryParts =
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id]; const selectedPartId = build[category.id];
const selectedPart = selectedPartId const selectedPart = selectedPartId
? categoryParts.find((p) => p.id === selectedPartId) ?? ? categoryParts.find(
parts.find((p) => p.id === selectedPartId) (p) => p.id === selectedPartId
) ?? parts.find((p) => p.id === selectedPartId)
: undefined; : undefined;
const hasParts = categoryParts.length > 0; const hasParts = categoryParts.length > 0;
@@ -992,10 +1237,27 @@ export default function GunbuilderPage() {
{selectedPart ? ( {selectedPart ? (
<> <>
<div className="text-[0.7rem] text-zinc-500 line-clamp-1"> <div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name} <Link
href={`/parts/p/${encodeURIComponent(
platform
)}/${encodeURIComponent(
category.id
)}/${encodeURIComponent(
`${
selectedPart.id
}-${selectedPart.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")}`
)}`}
className="block text-[0.7rem] text-zinc-500 line-clamp-1 transition-colors hover:text-amber-300 hover:underline underline-offset-2"
title="View part details"
>
{selectedPart.name}
</Link>{" "}
</div> </div>
{/* ✅ Category overlap chips */} {/* Overlap chips help explain conflicts / dependencies */}
<div className="mt-1 flex flex-wrap gap-1.5"> <div className="mt-1 flex flex-wrap gap-1.5">
{getOverlapChipsForCategory( {getOverlapChipsForCategory(
category.id as CategoryId, category.id as CategoryId,
@@ -1038,7 +1300,7 @@ export default function GunbuilderPage() {
</td> </td>
{/* Caliber placeholder */} {/* Caliber placeholder (future: derive from build profile) */}
<td className="px-3 py-2 align-top text-zinc-400"> <td className="px-3 py-2 align-top text-zinc-400">
</td> </td>
@@ -1077,10 +1339,9 @@ export default function GunbuilderPage() {
</> </>
) : hasParts ? ( ) : hasParts ? (
<Link <Link
href={{ href={`/parts/p/${encodeURIComponent(
pathname: `/parts/p/${platform}/${category.id}`, platform
query: platform ? { platform } : {}, )}/${encodeURIComponent(category.id)}`}
}}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors" className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
> >
<svg <svg
@@ -1120,4 +1381,4 @@ export default function GunbuilderPage() {
</div> </div>
</main> </main>
); );
} }
@@ -0,0 +1,16 @@
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
export default async function PartsBrowsePage({
params,
}: {
params: Promise<{ partRole: string }>;
}) {
const { partRole } = await params;
return (
<PartsBrowseClient
partRole={decodeURIComponent(partRole)}
platform={null} // let client read ?platform=... or default
/>
);
}
@@ -204,7 +204,7 @@ export default function ProductDetailsPage() {
href={{ pathname: "/builder", query: platform ? { platform } : {} }} href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="hover:text-zinc-300" className="hover:text-zinc-300"
> >
The Armory The Builder
</Link> </Link>
<span className="text-zinc-700">/</span> <span className="text-zinc-700">/</span>
<Link <Link
@@ -370,7 +370,7 @@ export default function CategoryPage() {
href={{ pathname: "/builder", query: platform ? { platform } : {} }} href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block" className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
> >
Back to The Armory Back to the Builder
</Link> </Link>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
@@ -5,19 +5,18 @@ import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/gunbuilder";
import { import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
PART_ROLE_TO_CATEGORY,
normalizePartRole,
} from "@/lib/catalogMappings";
/** /**
* API Shapes * API Shapes
* - OfferFromApi: what /api/v1/products/:id returns in offers[] * - OfferFromApi: what /api/v1/products/:id/offers returns
* - GunbuilderProductFromApi: the product detail contract (v1) * - GunbuilderProductFromApi: product detail contract (v1)
*/ */
type OfferFromApi = { type OfferFromApi = {
id?: string | number;
merchantName?: string | null; merchantName?: string | null;
price?: number | null; price?: number | null;
originalPrice?: number | null;
buyUrl?: string | null; buyUrl?: string | null;
inStock?: boolean | null; inStock?: boolean | null;
lastUpdated?: string | null; lastUpdated?: string | null;
@@ -43,7 +42,7 @@ type GunbuilderProductFromApi = {
shortDescription?: string | null; shortDescription?: string | null;
description?: string | null; description?: string | null;
// New: offers[] from the API // (May be empty depending on endpoint; we now fetch offers separately)
offers?: OfferFromApi[] | null; offers?: OfferFromApi[] | null;
}; };
@@ -72,11 +71,44 @@ function formatPrice(price: number | null | undefined): string {
return `$${price.toFixed(2)}`; return `$${price.toFixed(2)}`;
} }
/**
* Derive a friendly merchant label.
* Priority:
* 1) offer.merchantName (best)
* 2) hostname from offer.buyUrl (fallback)
* 3) "Retailer"
*/
function getHostFromUrl(url?: string | null): string | null {
if (!url) return null;
try {
const u = new URL(url);
return u.host.replace(/^www\./, "");
} catch {
return null;
}
}
function toTitleCase(s: string): string {
return s
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function merchantLabel(offer: OfferFromApi): string {
const name = offer.merchantName?.trim();
if (name) return name;
const host = getHostFromUrl(offer.buyUrl);
if (host) return toTitleCase(host.split(".")[0]);
return "Retailer";
}
/** /**
* Sort offers: * Sort offers:
* 1) lowest price first * 1) lowest price first
* 2) in-stock before out-of-stock * 2) in-stock before out-of-stock
* 3) merchant name tiebreaker * 3) merchant label tiebreaker
*/ */
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] { function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
return [...offers].sort((a, b) => { return [...offers].sort((a, b) => {
@@ -89,7 +121,7 @@ function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
const bStock = b.inStock === false ? 1 : 0; const bStock = b.inStock === false ? 1 : 0;
if (aStock !== bStock) return aStock - bStock; if (aStock !== bStock) return aStock - bStock;
return (a.merchantName ?? "").localeCompare(b.merchantName ?? ""); return merchantLabel(a).localeCompare(merchantLabel(b));
}); });
} }
@@ -153,9 +185,7 @@ export default function ProductDetailsPage() {
return () => window.removeEventListener("storage", onStorage); return () => window.removeEventListener("storage", onStorage);
}, []); }, []);
const selectedPartIdForCategory = categoryId const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
? build?.[categoryId]
: undefined;
const isSelected = const isSelected =
!!categoryId && !!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : ""); selectedPartIdForCategory === (numericId ? String(numericId) : "");
@@ -165,6 +195,13 @@ export default function ProductDetailsPage() {
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Offers fetch state (from /products/:id/offers)
const [offersFromApi, setOffersFromApi] = useState<OfferFromApi[]>([]);
const [offersLoading, setOffersLoading] = useState(false);
// Image modal state
const [isImageOpen, setIsImageOpen] = useState(false);
/** /**
* Route normalization: * Route normalization:
* If platform segment is missing/empty, rewrite to canonical /parts/p route. * If platform segment is missing/empty, rewrite to canonical /parts/p route.
@@ -181,14 +218,7 @@ export default function ProductDetailsPage() {
)}/${encodeURIComponent(productSlug)}?${qp.toString()}` )}/${encodeURIComponent(productSlug)}?${qp.toString()}`
); );
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
platformParam,
platform,
partRoleParam,
productSlug,
router,
searchParams,
]);
/** /**
* Fetch product details from API: * Fetch product details from API:
@@ -229,6 +259,41 @@ export default function ProductDetailsPage() {
return () => controller.abort(); return () => controller.abort();
}, [numericId]); }, [numericId]);
/**
* Fetch offers (rich offer data)
* GET /api/v1/products/:id/offers
*/
useEffect(() => {
if (!numericId) return;
const controller = new AbortController();
async function fetchOffers() {
try {
setOffersLoading(true);
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load offers (${res.status})`);
}
const data: OfferFromApi[] = await res.json();
setOffersFromApi(Array.isArray(data) ? data : []);
} catch (err: any) {
if (err?.name === "AbortError") return;
// offers failing shouldn't kill the page
setOffersFromApi([]);
} finally {
setOffersLoading(false);
}
}
fetchOffers();
return () => controller.abort();
}, [numericId]);
/** /**
* Builder selection toggle: * Builder selection toggle:
* - If selected -> remove from build * - If selected -> remove from build
@@ -261,19 +326,16 @@ export default function ProductDetailsPage() {
/** /**
* Offers normalization: * Offers normalization:
* - If product.offers exists, use & sort it (real offers). * - Prefer offers endpoint data
* - Otherwise, fallback to a single offer derived from product.buyUrl/product.price (legacy). * - Fallback to legacy single-offer derived from product if needed
*/ */
const offers = useMemo(() => { const offers = useMemo(() => {
if (!product) return []; if (offersFromApi.length > 0) return sortOffers(offersFromApi);
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[]; if (product?.buyUrl) {
if (raw.length > 0) return sortOffers(raw);
if (product.buyUrl) {
return sortOffers([ return sortOffers([
{ {
merchantName: product.merchantName ?? "Merchant", merchantName: product.merchantName ?? "Retailer",
price: product.price, price: product.price,
buyUrl: product.buyUrl, buyUrl: product.buyUrl,
inStock: product.inStock ?? true, inStock: product.inStock ?? true,
@@ -282,21 +344,30 @@ export default function ProductDetailsPage() {
} }
return []; return [];
}, [product]); }, [offersFromApi, product]);
/** /**
* Best offer (for the "Buy from ..." CTA) * Best offer (sorted offers[0])
* IMPORTANT: this must be defined AFTER offers is computed
* (do NOT reference `offers` inside the offers useMemo).
*/ */
const bestOffer = offers[0] ?? null; const bestOffer = offers[0] ?? null;
/** /**
* Helper: identify the best offer row * Helper: identify the best offer row
* We compare by buyUrl (stable + unique enough for MVP)
*/ */
const isBestOffer = (offer: OfferFromApi) => const isBestOffer = (offer: OfferFromApi) =>
!!bestOffer && offer.buyUrl && offer.buyUrl === bestOffer.buyUrl; !!bestOffer &&
offer.id != null &&
bestOffer.id != null &&
String(offer.id) === String(bestOffer.id);
useEffect(() => {
console.log("OFFERS", offers);
console.log("BEST", bestOffer);
}, [offers, bestOffer]);
// Use best offer for “truth” when available
const stockValue = bestOffer?.inStock ?? product?.inStock ?? null;
const currentPrice = bestOffer?.price ?? product?.price ?? null;
if (!categoryId) { if (!categoryId) {
return ( return (
@@ -366,35 +437,6 @@ export default function ProductDetailsPage() {
canonical /parts/p route. canonical /parts/p route.
</p> </p>
</div> </div>
{/* Platform switch (updates NEW route) */}
<div className="flex items-center gap-2">
<label
htmlFor="platform-select"
className="text-xs text-zinc-500"
>
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => {
const next = e.target.value;
router.replace(
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}`
);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div> </div>
</header> </header>
@@ -414,18 +456,25 @@ export default function ProductDetailsPage() {
</p> </p>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]"> <div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* Left: image + core actions */} {/* Left */}
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3"> <div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black"> <div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
{imageUrl ? ( {imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element <button
<img type="button"
src={imageUrl} onClick={() => setIsImageOpen(true)}
alt={product.name} className="h-full w-full"
className="h-full w-full object-contain p-2" title="Click to enlarge"
loading="lazy" >
/> {/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
</button>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600"> <div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
No image No image
@@ -439,12 +488,12 @@ export default function ProductDetailsPage() {
</span> </span>
<span <span
className={`text-xs font-semibold ${ className={`text-xs font-semibold ${
product.inStock === false stockValue === false
? "text-red-300" ? "text-red-300"
: "text-emerald-300" : "text-emerald-300"
}`} }`}
> >
{product.inStock === false ? "Out of stock" : "In stock"} {stockValue === false ? "Out of stock" : "In stock"}
</span> </span>
</div> </div>
@@ -467,20 +516,18 @@ export default function ProductDetailsPage() {
: "bg-amber-400 text-black hover:bg-amber-300" : "bg-amber-400 text-black hover:bg-amber-300"
}`} }`}
> >
{isSelected ? "Remove" : "Add"} {isSelected ? "Remove" : "Add to Build"}
</button> </button>
</div> </div>
</div> </div>
{/* Pricing history placeholder */} {/* Price history placeholder */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3"> <div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500"> <h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Price History Price History
</h3> </h3>
<span className="text-[10px] text-zinc-600"> <span className="text-[10px] text-zinc-600">placeholder</span>
placeholder
</span>
</div> </div>
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600"> <div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
Pricing graph will go here Pricing graph will go here
@@ -492,7 +539,7 @@ export default function ProductDetailsPage() {
</div> </div>
</div> </div>
{/* Right: details + offers */} {/* Right */}
<div className="min-w-0 space-y-4"> <div className="min-w-0 space-y-4">
{/* Product meta */} {/* Product meta */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4"> <div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
@@ -532,7 +579,7 @@ export default function ProductDetailsPage() {
Current price Current price
</div> </div>
<div className="mt-1 text-2xl font-semibold text-amber-300"> <div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(product.price)} {formatPrice(currentPrice)}
</div> </div>
</div> </div>
</div> </div>
@@ -560,7 +607,11 @@ export default function ProductDetailsPage() {
Merchant Offers Merchant Offers
</h3> </h3>
<span className="text-[10px] text-zinc-600"> <span className="text-[10px] text-zinc-600">
{offers.length ? `${offers.length} offers` : "no offers"} {offersLoading
? "loading…"
: offers.length
? `${offers.length} offers`
: "no offers"}
</span> </span>
</div> </div>
@@ -578,20 +629,19 @@ export default function ProductDetailsPage() {
<tbody className="divide-y divide-zinc-800"> <tbody className="divide-y divide-zinc-800">
{offers.map((o, idx) => ( {offers.map((o, idx) => (
<tr <tr
key={idx} key={String(o.id ?? idx)}
className={`hover:bg-zinc-900/40 ${ className={`hover:bg-zinc-900/40 ${
isBestOffer(o) isBestOffer(o)
? "bg-amber-400/10 border-l-2 border-amber-400" ? "bg-amber-400/10 border-l-2 border-amber-400"
: "" : ""
}`} }`}
> >
{" "}
<td className="px-3 py-2 text-zinc-200 flex items-center"> <td className="px-3 py-2 text-zinc-200 flex items-center">
<span>{o.merchantName ?? "Merchant"}</span> <span>{merchantLabel(o)}</span>
{isBestOffer(o) && ( {isBestOffer(o) && (
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black"> <span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
BEST BEST DEAL
</span> </span>
)} )}
</td> </td>
@@ -607,9 +657,7 @@ export default function ProductDetailsPage() {
)} )}
</td> </td>
<td className="px-3 py-2 text-right font-semibold text-amber-300"> <td className="px-3 py-2 text-right font-semibold text-amber-300">
{o.price != null {o.price != null ? `$${o.price.toFixed(2)}` : "—"}
? `$${o.price.toFixed(2)}`
: "—"}
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
{o.buyUrl ? ( {o.buyUrl ? (
@@ -622,9 +670,7 @@ export default function ProductDetailsPage() {
Buy Buy
</a> </a>
) : ( ) : (
<span className="text-xs text-zinc-600"> <span className="text-xs text-zinc-600"></span>
</span>
)} )}
</td> </td>
</tr> </tr>
@@ -639,7 +685,7 @@ export default function ProductDetailsPage() {
</p> </p>
)} )}
{/* Best Offer CTA (uses sorted offers[0]) */} {/* Best Offer CTA */}
{bestOffer?.buyUrl ? ( {bestOffer?.buyUrl ? (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<a <a
@@ -648,7 +694,7 @@ export default function ProductDetailsPage() {
rel="noreferrer" rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800" className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
> >
Buy from {bestOffer.merchantName ?? "Merchant"} Buy from {merchantLabel(bestOffer)}
</a> </a>
</div> </div>
) : null} ) : null}
@@ -656,21 +702,49 @@ export default function ProductDetailsPage() {
{/* Footer tips */} {/* Footer tips */}
<div className="flex flex-wrap gap-2 text-xs text-zinc-500"> <div className="flex flex-wrap gap-2 text-xs text-zinc-500">
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1"> {/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Tip: Add this part, then go back and compare alternates. Tip: Add this part, then go back and compare alternates.
</span> </span> */}
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1"> {/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Canonical route:{" "} Canonical route:{" "}
<span className="text-zinc-300"> <span className="text-zinc-300">
/parts/p/{platform}/{partRoleParam}/{productSlug} /parts/p/{platform}/{partRoleParam}/{productSlug}
</span> </span>
</span> </span> */}
</div> </div>
</div> </div>
</div> </div>
)} )}
</section> </section>
{/* Image modal */}
{isImageOpen && imageUrl ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setIsImageOpen(false)}
>
<div
className="relative max-h-[90vh] max-w-[90vw]"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setIsImageOpen(false)}
className="absolute -top-3 -right-3 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800"
>
Close
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={product?.name ?? "Product image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
/>
</div>
</div>
) : null}
</div> </div>
</main> </main>
); );
} }
+616
View File
@@ -0,0 +1,616 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = {
uuid: string;
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
items?: Array<{
slot: string;
productId: number;
productName?: string | null;
productBrand?: string | null;
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
}>;
photos?: BuildPhotoDto[];
};
type BuildPhotoDto = {
uuid: string;
url: string;
caption?: string | null;
sortOrder?: number | null;
createdAt?: string | null;
};
type LocalPreview = {
id: string;
file: File;
url: string; // object URL
};
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
}
export default function VaultBuildEditPage() {
const { uuid } = useParams<{ uuid: string }>();
const router = useRouter();
const api = useApi();
const { token, loading: authLoading } = useAuth();
const [build, setBuild] = useState<BuildDto | null>(null);
const [form, setForm] = useState({
title: "",
description: "",
isPublic: false,
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
// photo upload UI state
const [previews, setPreviews] = useState<LocalPreview[]>([]);
const [uploading, setUploading] = useState(false);
// avoid object URL leaks
useEffect(() => {
return () => {
previews.forEach((p) => URL.revokeObjectURL(p.url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const authed = !!token;
// Load build once auth is ready + token exists
useEffect(() => {
if (!uuid) return;
if (authLoading) return;
if (!authed) {
setError("Please log in to edit builds.");
return;
}
let cancelled = false;
(async () => {
try {
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 || `Failed to load build (${res.status})`);
}
const data = (await res.json()) as BuildDto;
if (cancelled) return;
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");
}
})();
return () => {
cancelled = true;
};
}, [uuid, authLoading, authed, api]);
const dirty = useMemo(() => {
if (!build) return false;
return (
form.title !== (build.title ?? "") ||
form.description !== (build.description ?? "") ||
form.isPublic !== Boolean(build.isPublic)
);
}, [build, form]);
async function onSave() {
if (!uuid) return;
setBusy(true);
setSavedMsg(null);
setError(null);
try {
const payload = {
title: form.title.trim() || "Untitled Build",
description: form.description.trim() || null,
isPublic: form.isPublic,
};
const res = await api.put(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`,
payload
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Save failed (${res.status})`);
}
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");
} finally {
setBusy(false);
}
}
function isImage(file: File) {
return file.type.startsWith("image/");
}
async function fileToUpload(file: File, buildUuid: string) {
// 1) ask backend for presigned upload URL
const res = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`,
{
fileName: file.name,
contentType: file.type,
}
);
if (!res.ok) {
throw new Error(await res.text().catch(() => "Failed to get upload URL"));
}
const { uploadUrl, publicUrl } = await res.json();
// 2) upload directly to storage
const put = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error("Upload failed");
// 3) confirm + create photo record
const create = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`,
{
url: publicUrl,
caption: null,
sortOrder: 0,
}
);
if (!create.ok) {
throw new Error(await create.text().catch(() => "Failed to save photo"));
}
return (await create.json()) as BuildPhotoDto;
}
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 (
<main className="mx-auto max-w-3xl p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Edit Build</h1>
<div className="mt-1 text-xs text-zinc-500">
UUID: <span className="font-mono">{uuid}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link
href="/vault"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
>
Vault
</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>
{authLoading && <div className="text-sm text-zinc-400">Loading</div>}
{!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}
</pre>
)}
{!authLoading && build && (
<div className="space-y-4">
{/* meta */}
<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="text-xs text-zinc-400">
Created: {fmt(build.createdAt)} Updated:{" "}
{fmt(build.updatedAt)}
</div>
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${
form.isPublic
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
>
{form.isPublic ? "Published" : "Draft"}
</span>
</div>
</div>
{/* parts */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold">Parts in this build</div>
<div className="text-xs text-zinc-400">
{build.items?.length ? `${build.items.length} items` : "—"}
</div>
</div>
{!build.items?.length ? (
<div className="text-sm opacity-70">No parts saved yet.</div>
) : (
<div className="overflow-hidden rounded-lg border border-white/10 bg-black/20">
{/* header */}
<div className="grid grid-cols-12 gap-3 border-b border-white/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
<div className="col-span-5">Product</div>
<div className="col-span-4">Component (Role)</div>
<div className="col-span-2">Brand</div>
<div className="col-span-1 text-right">Price</div>
</div>
{/* rows */}
{build.items
.slice()
.sort((a, b) => a.slot.localeCompare(b.slot))
.map((it, idx) => {
const price =
typeof it.bestPrice === "number"
? it.bestPrice
: it.bestPrice
? Number(it.bestPrice)
: null;
return (
<div
key={`${it.slot}-${it.productId}-${idx}`}
className="grid grid-cols-12 gap-2 border-b border-white/5 px-3 py-2 text-sm last:border-b-0"
>
{/* Product */}
<div className="col-span-5 min-w-0">
<div className="truncate text-zinc-100">
{it.productName ?? `Product #${it.productId}`}
</div>
<div className="text-xs text-zinc-500">
ID:{" "}
<span className="font-mono">{it.productId}</span>
</div>
</div>
{/* Component (Role) */}
<div className="col-span-4 truncate text-zinc-200">
{it.slot}
</div>
{/* Brand */}
<div className="col-span-2 truncate text-zinc-300">
{it.productBrand ?? "—"}
</div>
{/* Price */}
<div className="col-span-1 text-right font-semibold text-zinc-100">
{price != null && !Number.isNaN(price)
? `$${price.toFixed(2)}`
: "—"}
</div>
</div>
);
})}
</div>
)}
</div>
{/* form */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<label className="block text-xs font-medium text-zinc-300">
Title
</label>
<input
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
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}
/>
<label className="mt-4 block text-xs font-medium text-zinc-300">
Notes / Description
</label>
<textarea
value={form.description}
onChange={(e) =>
setForm((f) => ({ ...f, description: e.target.value }))
}
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"
placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
maxLength={2000}
/>
<div className="mt-4 flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.isPublic}
onChange={(e) =>
setForm((f) => ({ ...f, isPublic: e.target.checked }))
}
/>
<span className="text-zinc-200">Publish to community</span>
</label>
<div className="flex items-center gap-2">
{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>
{/* Build Photos */}
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">Build Photos</div>
<div className="text-xs opacity-70">
Select photos, remove any you dont want, then upload.
</div>
</div>
<div className="flex items-center gap-2">
{/* Select (no upload) */}
<label
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
uploading ? "opacity-60 pointer-events-none" : ""
}`}
title={uploading ? "Uploading…" : "Select photos"}
>
Select Photos
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []).filter(
isImage
);
if (!files.length) return;
const local: LocalPreview[] = files.map((f) => ({
id:
typeof crypto !== "undefined" &&
"randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random()}`,
file: f,
url: URL.createObjectURL(f),
}));
setPreviews((prev) => [...local, ...prev]);
e.target.value = ""; // reset input so re-selecting same file works
}}
/>
</label>
{/* Upload (real upload) */}
<button
type="button"
disabled={uploading || previews.length === 0 || !build?.uuid}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
onClick={async () => {
if (!build?.uuid || previews.length === 0) return;
setError(null);
setUploading(true);
// snapshot so user can keep selecting more while current batch uploads
const batch = [...previews];
try {
for (const p of batch) {
// if user removed it before we got to it, skip
const stillThere = previews.some((x) => x.id === p.id);
if (!stillThere) continue;
const created = await fileToUpload(p.file, build.uuid);
setBuild((prev) =>
prev
? {
...prev,
photos: [created, ...(prev.photos ?? [])],
}
: prev
);
// remove preview after success
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}
} catch (err: any) {
setError(err?.message ?? "Photo upload failed");
} finally {
setUploading(false);
}
}}
>
{uploading
? "Uploading…"
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
</button>
</div>
</div>
{/* Gallery */}
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{/* Local previews (pending upload) */}
{previews.map((p) => (
<div
key={p.id}
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
{/* remove X */}
<button
type="button"
aria-label="Remove"
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
onClick={() => {
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}}
disabled={uploading}
title={
uploading
? "Wait for upload to finish"
: "Remove from upload list"
}
>
</button>
<img
src={p.url}
alt="Pending upload"
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80">
{uploading ? "Uploading…" : "Pending upload"}
</div>
</div>
))}
{/* Uploaded photos */}
{(build?.photos ?? []).map((p) => (
<div
key={p.uuid}
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
<img
src={p.url}
alt={p.caption ?? "Build photo"}
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80 line-clamp-2">
{p.caption ?? "—"}
</div>
</div>
))}
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
<div className="col-span-full text-xs opacity-70">
No photos yet. Select a few and upload when ready.
</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>
)}
</main>
);
}
+255
View File
@@ -0,0 +1,255 @@
// app/vault/page.tsx
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = {
id: string; // internal numeric id (not used by UI)
uuid: string; // public identifier
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// UUID validator (defensive)
const isUuid = (v: string) =>
/^[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
);
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d;
return dt.toLocaleString();
}
export default function VaultPage() {
const router = useRouter();
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 [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const authed = !!user && !!token;
// Redirect if not logged in (after auth hydrates)
useEffect(() => {
if (!authLoading && !authed) {
router.replace("/login");
}
}, [authLoading, authed, router]);
// 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
useEffect(() => {
if (authLoading || !authed) return;
let cancelled = false;
(async () => {
setBusy(true);
setError(null);
try {
// IMPORTANT:
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
const res = await api.get("/api/v1/builds/me");
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const data = (await res.json()) as BuildDto[];
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds");
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [authLoading, authed, api]);
if (authLoading) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
}
// Already redirected
if (!authed) return null;
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Vault</h1>
<p className="text-sm opacity-70">
Your saved builds. Open one to keep tweaking parts, or add details
to submit it to the community.
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/builder"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
title="Create a new build in the builder"
>
New Build
</Link>
<Link href="/account" className="text-sm opacity-80 hover:underline" title={"Your account info"}>
Account
</Link>
</div>
</div>
{/* Error */}
{error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error}
</div>
)}
{/* Builds list */}
<div className="rounded-xl border border-white/10 bg-white/5">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="text-sm font-medium">My Builds</div>
<div className="text-xs opacity-70">
{busy ? "Refreshing…" : `${builds.length} total`}
</div>
</div>
{builds.length === 0 ? (
<div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save As
</div>
) : (
<ul className="divide-y divide-white/10">
{builds.map((b) => {
const valid = !!b?.uuid && isUuid(String(b.uuid));
const published = Boolean(b.isPublic);
const openHref = valid
? `/builder?load=${encodeURIComponent(b.uuid)}`
: "#";
const detailsHref = valid
? `/vault/${encodeURIComponent(b.uuid)}/edit`
: "#";
return (
<li
key={b.uuid}
className="p-4 flex items-start justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{b.title}</div>
{/* Status chip */}
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${
published
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
title={
published
? "This build is public (community-visible)."
: "Draft (only visible in your Vault)."
}
>
{published ? "Published" : "Draft"}
</span>
{!valid && (
<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"
title="This build record has an invalid UUID."
>
Invalid UUID
</span>
)}
</div>
<div className="mt-1 text-xs opacity-70">
Updated: {fmt(b.updatedAt)} UUID:{" "}
<span className="font-mono">{b.uuid}</span>
</div>
{b.description && (
<div className="mt-2 text-sm opacity-80 line-clamp-2">
{b.description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href={openHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Load this build into the builder" : "Invalid build id"}
>
View in Builder
</Link>
<Link
href={detailsHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Edit details / publish later" : "Invalid build id"}
>
Edit Title
</Link>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import { Button, Field, Input } from "@/components/ui/form";
export default function AdminBetaInvitesPage() {
const [dryRun, setDryRun] = useState(true);
const [limit, setLimit] = useState("1");
const [tokenMinutes, setTokenMinutes] = useState("30");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
async function runInvites() {
setLoading(true);
setError(null);
setResult(null);
try {
const qs = new URLSearchParams({
dryRun: String(dryRun),
limit: String(limit || "0"),
tokenMinutes: String(tokenMinutes || "30"),
});
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
setResult(data);
} catch (e: any) {
setError(e?.message ?? "Failed");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender (uses your email templates + tracking).
</p>
</div>
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
Dry run (do everything except actually send)
</label>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Limit" htmlFor="limit">
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
</Field>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
/>
</Field>
</div>
<Button onClick={runInvites} disabled={loading}>
{loading ? "Running…" : "Send Beta Invites"}
</Button>
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{result && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
</div>
);
}
@@ -0,0 +1,585 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, X, Wand2, RefreshCw } from "lucide-react";
type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED";
type EnrichmentType = "CALIBER" | "CALIBER_GROUP";
type EnrichmentSource = "RULES" | "AI";
type QueueItem = {
id: number;
productId: number;
productName?: string;
productSlug?: string;
mainImageUrl?: string;
brandName?: string;
enrichmentType: EnrichmentType;
source: EnrichmentSource;
status: EnrichmentStatus;
attributes: Record<string, any>;
confidence: number;
rationale?: string;
createdAt: string;
// NEW: current value on products table (so we can prevent bad applies)
productCaliber?: string | null;
productCaliberGroup?: string | null;
};
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
function getAuthHeaders(): HeadersInit {
if (typeof window === "undefined") return {};
const token =
localStorage.getItem("token") ||
localStorage.getItem("jwt") ||
localStorage.getItem("accessToken");
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function apiFetch(path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
...(init?.headers ?? {}),
},
// If you're using cookie auth instead of bearer, flip this on:
// credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return res.json();
return res.text();
}
function hasProductCaliber(it: QueueItem) {
const c = it.productCaliber;
return typeof c === "string" && c.trim().length > 0;
}
function hasAlreadySet(it: QueueItem) {
if (it.enrichmentType === "CALIBER") {
return hasProductCaliber(it);
}
const g = it.productCaliberGroup;
return typeof g === "string" && g.trim().length > 0;
}
// ✅ ADD THIS: text for the “Already set:” pill
function alreadySetLabel(it: QueueItem) {
return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup;
}
export default function EnrichmentQueueClient() {
const [type, setType] = useState<EnrichmentType>("CALIBER");
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
const [limit, setLimit] = useState<number>(50);
const [items, setItems] = useState<QueueItem[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Bulk selection
const [selected, setSelected] = useState<Record<number, boolean>>({});
// “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately
// so the row swaps buttons without waiting for a reload.
const [localStatus, setLocalStatus] = useState<
Record<number, EnrichmentStatus>
>({});
const count = useMemo(() => items.length, [items]);
const selectedIds = useMemo(
() =>
Object.entries(selected)
.filter(([, v]) => v)
.map(([k]) => Number(k)),
[selected]
);
const allOnPageSelected = useMemo(() => {
if (items.length === 0) return false;
return items.every((it) => selected[it.id]);
}, [items, selected]);
function toggleAllOnPage() {
const next = { ...selected };
const nextValue = !allOnPageSelected;
for (const it of items) next[it.id] = nextValue;
setSelected(next);
}
function toggleOne(id: number) {
setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
}
function effectiveStatus(it: QueueItem): EnrichmentStatus {
return localStatus[it.id] ?? it.status;
}
async function load() {
setLoading(true);
setError(null);
try {
const data = (await apiFetch(
`/api/admin/enrichment/queue2?type=${encodeURIComponent(
type
)}&status=${encodeURIComponent(status)}&limit=${limit}`
)) as QueueItem[];
setItems(data ?? []);
// Keep selection only for items still visible
setSelected((prev) => {
const keep = new Set((data ?? []).map((x) => x.id));
const next: Record<number, boolean> = {};
for (const [k, v] of Object.entries(prev)) {
const id = Number(k);
if (keep.has(id)) next[id] = v;
}
return next;
});
// Reset local status for items not in view
setLocalStatus((prev) => {
const keep = new Set((data ?? []).map((x) => x.id));
const next: Record<number, EnrichmentStatus> = {};
for (const [k, v] of Object.entries(prev)) {
const id = Number(k);
if (keep.has(id)) next[id] = v;
}
return next;
});
} catch (e: any) {
setError(e?.message ?? "Failed to load queue");
} finally {
setLoading(false);
}
}
async function runRules() {
setLoading(true);
setError(null);
try {
await apiFetch(
`/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`,
{ method: "POST" }
);
setStatus("PENDING_REVIEW");
setTimeout(load, 50);
} catch (e: any) {
setError(e?.message ?? "Failed to run rules");
setLoading(false);
}
}
async function runAi() {
setLoading(true);
setError(null);
try {
await apiFetch(
`/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`,
{ method: "POST" }
);
setStatus("PENDING_REVIEW");
setTimeout(load, 50);
} catch (e: any) {
setError(e?.message ?? "Failed to run AI");
setLoading(false);
}
}
async function act(id: number, action: "approve" | "reject" | "apply") {
setBusyId(id);
setError(null);
// Optimistic UI for approve/reject so the row instantly changes
if (action === "approve")
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
if (action === "reject")
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
try {
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
method: "POST",
});
await load();
} catch (e: any) {
// rollback optimistic local status on error
setLocalStatus((p) => {
const next = { ...p };
delete next[id];
return next;
});
setError(e?.message ?? `Failed to ${action}`);
} finally {
setBusyId(null);
}
}
async function bulk(action: "approve" | "reject" | "apply") {
const ids = selectedIds;
if (ids.length === 0) return;
setLoading(true);
setError(null);
try {
// Apply should only run for items that are APPROVED (effective status) AND product caliber is blank
const byId = new Map(items.map((x) => [x.id, x]));
const filtered =
action === "apply"
? ids.filter((id) => {
const it = byId.get(id);
if (!it) return false;
return (
effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it)
);
})
: ids;
for (const id of filtered) {
// optimistic statuses for approve/reject in bulk
if (action === "approve")
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
if (action === "reject")
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
method: "POST",
});
}
setSelected({});
await load();
} catch (e: any) {
setError(e?.message ?? `Failed to bulk ${action}`);
setLoading(false);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, status, limit]);
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm text-zinc-400">
Showing <span className="text-zinc-200 font-medium">{count}</span>
</div>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={type}
onChange={(e) => setType(e.target.value as EnrichmentType)}
>
<option value="CALIBER">CALIBER</option>
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={status}
onChange={(e) => {
setSelected({});
setLocalStatus({});
setStatus(e.target.value as EnrichmentStatus);
}}
>
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
<option value="APPLIED">APPLIED</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={limit}
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={load}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</button>
<button
type="button"
onClick={runRules}
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
disabled={loading}
title="Run enrichment rules to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run Rules
</button>
<button
type="button"
onClick={runAi}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
disabled={loading}
title="Run AI enrichment to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run AI
</button>
</div>
</div>
{/* Bulk action bar */}
{selectedIds.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="text-sm text-zinc-300">
Selected{" "}
<span className="font-semibold">{selectedIds.length}</span>
<span className="ml-2 text-xs text-zinc-500">
(Apply only works on APPROVED + product caliber blank)
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => bulk("approve")}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Approve Selected
</button>
<button
type="button"
onClick={() => bulk("reject")}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Reject Selected
</button>
<button
type="button"
onClick={() => bulk("apply")}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
disabled={loading}
title="Applies only APPROVED rows where product caliber is blank"
>
Apply Selected
</button>
<button
type="button"
onClick={() => setSelected({})}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Clear
</button>
</div>
</div>
) : null}
</div>
{error ? (
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
{error}
</div>
) : null}
{/* Table */}
<div className="overflow-hidden rounded-md border border-zinc-800">
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={toggleAllOnPage}
className="h-4 w-4 accent-amber-400"
aria-label="Select all"
/>
</div>
<div>Product</div>
<div className="text-right">Confidence</div>
<div className="text-right">Suggested</div>
<div className="text-right">Actions</div>
</div>
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
{items.length === 0 ? (
<div className="p-6 text-sm text-zinc-400">No items found.</div>
) : (
items.map((it) => {
const suggested =
it.enrichmentType === "CALIBER"
? it.attributes?.caliber
: it.attributes?.caliberGroup; const isBusy = busyId === it.id;
const st = effectiveStatus(it);
const alreadySet = hasAlreadySet(it);
const alreadySetValue = alreadySetLabel(it);
return (
<div
key={it.id}
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={!!selected[it.id]}
onChange={() => toggleOne(it.id)}
className="h-4 w-4 accent-amber-400"
aria-label={`Select enrichment ${it.id}`}
/>
</div>
<div className="min-w-0">
<div className="flex items-start gap-3">
{it.mainImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.mainImageUrl}
alt=""
className="h-10 w-10 rounded border border-zinc-800 object-cover"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
)}
<div className="min-w-0">
<a
href={`/admin/products/${it.productId}`}
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
>
{it.productName ?? `Product #${it.productId}`}
</a>
<div className="mt-0.5 text-xs text-zinc-500">
{it.brandName ? `${it.brandName}` : ""}
{it.source} {st}
{alreadySet ? (
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
Already set: {alreadySetValue}
</span>
) : null}
</div>
{it.rationale ? (
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
{it.rationale}
</div>
) : null}
</div>
</div>
</div>
<div className="text-right text-sm font-semibold text-zinc-200">
{(it.confidence ?? 0).toFixed(2)}
</div>
<div className="text-right text-sm text-amber-300">
{suggested ?? "—"}
</div>
<div className="flex justify-end gap-2">
{st === "PENDING_REVIEW" && (
<>
<button
type="button"
onClick={() => act(it.id, "approve")}
disabled={isBusy || alreadySet}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
title={
alreadySet
? "Product caliber already set"
: "Approve"
}
aria-label="Approve"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => act(it.id, "reject")}
disabled={isBusy}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
title="Reject"
aria-label="Reject"
>
<X className="h-4 w-4" />
</button>
</>
)}
{st === "APPROVED" && (
<button
type="button"
onClick={() => act(it.id, "apply")}
disabled={isBusy || alreadySet}
className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
title={
alreadySet
? `Already set on product: ${alreadySetValue}`
: "Apply (writes to product if blank)"
}
>
Apply
</button>
)}
{st === "APPLIED" && (
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
Applied
</span>
)}
{st === "REJECTED" && (
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
Rejected
</span>
)}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import EnrichmentQueueClient from "./EnrichmentQueueClient";
export const dynamic = "force-dynamic";
export default function AdminEnrichmentPage() {
return (
<div className="mx-auto w-full max-w-6xl px-6 py-8">
<div className="mb-6">
<h1 className="text-xl font-semibold text-zinc-50">Enrichment Queue</h1>
<p className="mt-1 text-sm text-zinc-400">
Review AI/rules suggestions, approve/reject, then apply to products.
</p>
</div>
<EnrichmentQueueClient />
</div>
);
}
+83 -88
View File
@@ -1,7 +1,10 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import AdminLeftNavigation from "@/components/AdminLeftNavigation"; import AdminLeftNavigation from "@/components/AdminLeftNavigation";
import { useAuth } from "@/context/AuthContext";
import { import {
LayoutDashboard, LayoutDashboard,
Download, Download,
@@ -11,110 +14,102 @@ import {
Users, Users,
Settings, Settings,
LucideMail, LucideMail,
Wand2,
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
{ { label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
label: "Dashboard", { label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
href: "/admin", { label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
icon: <LayoutDashboard className="h-4 w-4" />, { label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
}, { label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
{ { label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
label: "Imports", { label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
href: "/admin/import-status", { label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
icon: <Download className="h-4 w-4" />, { label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
}, { label: "List Emails", href: "/admin/email", icon: <LucideMail className="h-4 w-4" /> },
{ { label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
label: "Mappings", { label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" /> },
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
{
label: "List Emails",
href: "/admin/email",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
},
]; ];
// ... existing code ...
// ADMIN CHECK FOR LOGIN
// const { user, loading } = useAuth();
// if (!loading && user?.role !== "ADMIN") {
// redirect("/"); // or /login
// }
export default function AdminLayout({ export default function AdminLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
// Where to send people back after login
const next = useMemo(
() => encodeURIComponent(pathname || "/admin"),
[pathname]
);
/**
* ✅ AUTH GUARD
* Redirects happen in useEffect (NOT during render)
*/
useEffect(() => {
if (loading) return;
// Not logged in
if (!user) {
router.replace(`/login?next=${next}`);
return;
}
// Logged in but not admin
if (user.role !== "ADMIN") {
router.replace("/");
}
}, [loading, user, router, next]);
// While loading OR redirecting, render nothing
if (loading) return null;
if (!user) return null;
if (user.role !== "ADMIN") return null;
return ( return (
<div className="flex min-h-screen bg-black text-zinc-50"> <div className="flex min-h-screen bg-black text-zinc-50">
<AdminLeftNavigation <AdminLeftNavigation
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)} onToggleCollapsed={() => setCollapsed((v) => !v)}
/> items={navItems}
/>
{/* Main column */} {/* Main column */}
<div className="flex min-h-screen flex-1 flex-col"> <div className="flex min-h-screen flex-1 flex-col">
{/* Top bar */} {/* Top bar */}
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3"> <header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
<div> <div>
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500"> <p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Admin Admin
</p> </p>
<p className="text-sm text-zinc-200"> <p className="text-sm text-zinc-200">
Battl Builders Control Panel Battl Builders Control Panel
</p> </p>
</div> </div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300"> <span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
Internal v0.1 Internal v0.1
</span> </span>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
ADMIN ADMIN
</div>
</div> </div>
</header> </div>
</header>
{/* Content */} {/* Content */}
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6"> <main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
{children} {children}
</main> </main>
</div>
</div> </div>
</div>
); );
} }
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) {
const token = cookies().get("bb_access_token")?.value;
if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
}
const url = new URL(req.url);
const dryRun = url.searchParams.get("dryRun") ?? "true";
const limit = url.searchParams.get("limit") ?? "0";
const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
const res = await fetch(upstream, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
});
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { token, role } = await req.json();
if (!token) {
return NextResponse.json({ ok: false }, { status: 400 });
}
const res = NextResponse.json({ ok: true });
// Server-readable, JS-unreadable
res.cookies.set("bb_access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
// Optional: for quick middleware role checks (not “secure” by itself)
if (role) {
res.cookies.set("bb_role", role, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}
return res;
}
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.set("bb_access_token", "", { path: "/", maxAge: 0 });
res.cookies.set("bb_role", "", { path: "/", maxAge: 0 });
return res;
}
+14 -46
View File
@@ -1,61 +1,29 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
const BREVO_API_KEY = process.env.BREVO_API_KEY; const API_BASE_URL =
const BREVO_LIST_ID = process.env.BREVO_LIST_ID; // optional process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export async function POST(req: Request) { export async function POST(req: Request) {
if (!BREVO_API_KEY) {
console.error("BREVO_API_KEY is not set");
return NextResponse.json(
{ error: "Server not configured" },
{ status: 500 }
);
}
try { try {
const { email } = await req.json(); const body = await req.json();
if (!email || typeof email !== "string") { const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
return NextResponse.json(
{ error: "Valid email is required" },
{ status: 400 }
);
}
// Brevo contacts API
const res = await fetch("https://api.brevo.com/v3/contacts", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify(body),
"api-key": BREVO_API_KEY, cache: "no-store",
},
body: JSON.stringify({
email,
updateEnabled: true, // update if already exists
...(BREVO_LIST_ID
? { listIds: [Number(BREVO_LIST_ID)] }
: {}),
attributes: {
SOURCE: "Battl Builders Beta",
},
}),
}); });
// Always return ok=true (matches your server behavior)
if (!res.ok) { if (!res.ok) {
const text = await res.text(); // Optional: log server response for debugging
console.error("Brevo error:", res.status, text); const text = await res.text().catch(() => "");
return NextResponse.json( console.error("beta-signup proxy failed:", res.status, text);
{ error: "Failed to subscribe" },
{ status: 500 }
);
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (err) { } catch (e) {
console.error(err); console.error("beta-signup proxy error:", e);
return NextResponse.json( return NextResponse.json({ ok: true }); // keep “no enumeration”
{ error: "Something went wrong" },
{ status: 500 }
);
} }
} }
+83
View File
@@ -0,0 +1,83 @@
// app/beta/confirm/page.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export default function BetaConfirmPage() {
const params = useSearchParams();
const token = params.get("token");
const router = useRouter();
const { setSession } = useAuth();
const ran = useRef(false);
const [message, setMessage] = useState("Confirming your email…");
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading"
);
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Missing token.");
return;
}
if (ran.current) return;
ran.current = true;
(async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/auth/beta/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Confirm link invalid/expired");
}
const data = await res.json();
const jwt = data.token ?? data.accessToken;
if (!jwt) throw new Error("No token returned from server");
setSession(jwt, {
email: data.email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
});
setStatus("success");
setMessage("Confirmed. Signing you in…");
router.replace("/account?welcome=1");
} catch (e: any) {
setStatus("error");
setMessage(
e?.message ||
"That link is invalid or expired. Please request a new one."
);
}
})();
}, [token, router, setSession]);
return (
<main className="min-h-screen flex items-center justify-center bg-black text-white px-6">
<div className="max-w-md w-full rounded-xl border border-white/10 bg-white/5 p-6 text-center">
<h1 className="text-lg font-semibold">Battl Builders</h1>
<p className="mt-2 text-sm text-zinc-300">{message}</p>
{status === "error" && (
<p className="mt-4 text-xs text-zinc-400">
Go back and request a fresh sign-in link.
</p>
)}
</div>
</main>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function BetaMagicPage() {
const params = useSearchParams();
const token = params.get("token");
const router = useRouter();
const { setSession } = useAuth();
const ran = useRef(false);
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("Signing you in…");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Missing token.");
return;
}
// ✅ Prevent double-call (Strict Mode / rerenders)
if (ran.current) return;
ran.current = true;
(async () => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"}/api/auth/magic/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Magic link failed");
}
const data = await res.json();
// Your AuthContext expects { token, email, displayName, role }
// and stores it in localStorage.
// We can simulate "login" by directly setting localStorage like AuthContext does,
// but easiest is just to store it here in the same shape.
setSession(data.token ?? data.accessToken, {
email: data.email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
});
setStatus("success");
setMessage("Youre in. Redirecting…");
router.replace("/account?welcome=1");
} catch (e: any) {
setStatus("error");
setMessage(e?.message || "Magic link expired. Request a new one.");
}
})();
}, [token, router, setSession]);
return (
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
<div className="max-w-md w-full rounded-xl border border-white/10 bg-white/5 p-6">
<h1 className="text-lg font-semibold">Battl Builders</h1>
<p className="mt-2 text-sm text-zinc-300">{message}</p>
{status === "error" && (
<div className="mt-4 text-xs text-zinc-400">
Try going back to your inbox and requesting a fresh sign-in link.
</div>
)}
</div>
</main>
);
}
+407
View File
@@ -0,0 +1,407 @@
// app/builds/[buildId]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
function formatMoney(n?: number | null) {
if (n == null) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(n);
}
function timeAgo(iso?: string | null) {
if (!iso) return "";
const d = new Date(iso);
const diff = Math.max(0, Date.now() - d.getTime());
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export default async function BuildBreakdownPage({
params,
}: {
params: { buildId: string };
}) {
// Public detail endpoint
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
cache: "no-store",
});
if (res.status === 404) return notFound();
const build = await res.json().catch(() => null);
if (!build) return notFound();
const items: any[] = build.items ?? [];
const total = items.reduce((sum, it) => {
const qty = Number(it.quantity ?? 1);
const price = it.bestPrice == null ? 0 : Number(it.bestPrice);
return sum + qty * price;
}, 0);
const images: any[] = build.images ?? build.photos ?? build.media ?? [];
return (
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Header crumb */}
<div className="flex items-center justify-between">
<Link
href="/builds"
className="text-sm text-zinc-400 hover:text-zinc-200"
>
Back to Community Builds
</Link>
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-0.5">
Public Build
</span>
{build.updatedAt && (
<span className="hidden md:inline">
updated {timeAgo(build.updatedAt)}
</span>
)}
</div>
</div>
{/* Layout */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
{/* Post column */}
<div className="space-y-4">
{/* “Reddit-ish” Post Card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60">
<div className="flex">
{/* Vote rail */}
<div className="flex w-12 flex-col items-center gap-2 border-r border-zinc-900 bg-black/20 py-4 text-zinc-500">
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
<div className="text-xs font-semibold text-zinc-400"></div>
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
</div>
{/* Post body */}
<div className="flex-1 p-5">
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-500">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-amber-300">
r/BattlBuilders
</span>
<span></span>
<span>posted by</span>
<span className="text-zinc-300">u/anonymous</span>
{build.createdAt && (
<>
<span></span>
<span>{timeAgo(build.createdAt)}</span>
</>
)}
</div>
{/* Title */}
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-50">
{build.title ?? "Untitled Build"}
</h1>
{/* Description */}
{build.description ? (
<p className="mt-2 text-sm text-zinc-300/90">
{build.description}
</p>
) : (
<p className="mt-2 text-sm text-zinc-500">
{/* placeholder copy */}
Field notes coming soon. This build breakdown will include
purpose, tradeoffs, and why each part made the cut.
</p>
)}
{/* Action bar */}
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Comment
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Share
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Save
</button>
<div className="ml-auto flex items-center gap-2 text-zinc-500">
<span className="hidden sm:inline">Est. Total</span>
<span className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 text-zinc-200">
{formatMoney(total)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Parts card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Parts</h2>
<div className="text-xs text-zinc-500">{items.length} items</div>
</div>
<div className="mt-4 space-y-2">
{items.map((it) => (
<div
key={it.uuid ?? it.id}
className="flex items-center gap-3 rounded-lg border border-zinc-900 bg-black/30 px-3 py-3"
>
{/* Thumb */}
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{it.productImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
IMG
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-zinc-100">
{it.productName ?? "Part"}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-md border border-zinc-800 bg-black/20 px-1.5 py-0.5">
{it.slot ?? "slot"}
</span>
{it.productBrand && <span>{it.productBrand}</span>}
<span className="text-zinc-600"></span>
<span>x{it.quantity ?? 1}</span>
</div>
</div>
{/* Price */}
<div className="flex flex-col items-end gap-1">
<div className="text-sm font-medium text-zinc-200">
{formatMoney(it.bestPrice)}
</div>
<button className="text-[11px] text-amber-300/90 hover:text-amber-200">
View part
</button>
</div>
</div>
))}
</div>
</div>
{/* Gallery */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">
Gallery
</h2>
<span className="text-xs text-zinc-500">user submitted</span>
</div>
{images.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4">
<p className="text-sm text-zinc-400">
No photos yet. Be the first to add a build pic.
</p>
<p className="mt-1 text-[11px] text-zinc-600">
(Upload UI coming soon well store and moderate images
per build.)
</p>
{/* Mock thumbnails */}
<div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-md border border-zinc-800 bg-zinc-950 flex items-center justify-center text-[10px] text-zinc-600"
>
IMG
</div>
))}
</div>
<button
className="mt-4 rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo (coming soon)
</button>
</div>
) : (
<div className="mt-4">
{/* “Hero” preview */}
<div className="aspect-[16/9] overflow-hidden rounded-lg border border-zinc-900 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images[0].url ?? images[0]}
alt="Build photo"
className="h-full w-full object-cover"
/>
</div>
{/* Thumbs */}
<div className="mt-3 grid grid-cols-4 gap-2 sm:grid-cols-6">
{images.slice(0, 12).map((img, idx) => (
<div
key={img.uuid ?? img.id ?? idx}
className="aspect-square overflow-hidden rounded-md border border-zinc-900 bg-black/30"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.thumbUrl ?? img.url ?? img}
alt={`Build photo ${idx + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
View all
</button>
<button
className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo
</button>
<span className="ml-auto text-[11px] text-zinc-600">
{images.length} photos
</span>
</div>
</div>
)}
</div>
{/* Comments (placeholder) */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Comments</h2>
<span className="text-xs text-zinc-500">mocked</span>
</div>
<div className="mt-4 space-y-4">
{[
{
user: "u/gearhead",
body: "Solid parts list. Any reason you went with that upper over a BCM?",
},
{
user: "u/OP",
body: "Mostly availability + price. Ill probably swap once I track deals for a week.",
},
{
user: "u/boltcarrier",
body: "Would love to see this with a pinned/weld option for 14.5 builds.",
},
].map((c, idx) => (
<div key={idx} className="flex gap-3">
<div className="mt-1 h-6 w-6 rounded-full bg-zinc-900 text-[10px] text-zinc-300 flex items-center justify-center">
{c.user === "u/OP" ? "OP" : "u"}
</div>
<div className="flex-1">
<div className="text-[11px] text-zinc-500">
<span className="text-zinc-300">{c.user}</span> 1h ago
</div>
<div className="mt-1 text-sm text-zinc-300/90">
{c.body}
</div>
<div className="mt-2 flex items-center gap-3 text-[11px] text-zinc-500">
<button className="hover:text-amber-300">Reply</button>
<button className="hover:text-amber-300">Share</button>
<button className="hover:text-amber-300">Save</button>
</div>
</div>
</div>
))}
<div className="rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4 text-sm text-zinc-500">
Commenting is coming soon. This will become the breakdown
thread for the build (notes, tradeoffs, deal alerts, revisions).
</div>
</div>
</div>
</div>
{/* Sidebar */}
<aside className="space-y-4">
{/* Build stats */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Build Summary
</h3>
<div className="mt-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-zinc-500">Build ID</span>
<span className="text-zinc-300">
{(build.uuid ?? params.buildId).slice(0, 8)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Visibility</span>
<span className="text-zinc-300">
{build.isPublic ? "Public" : "Private"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Items</span>
<span className="text-zinc-300">{items.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Est. Total</span>
<span className="text-zinc-50 font-medium">
{formatMoney(total)}
</span>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<button className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition">
Open in Builder
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
Copy share link
</button>
</div>
</div>
{/* Placeholder “OP notes” */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
OP Notes
</h3>
<ul className="mt-3 space-y-2 text-sm text-zinc-400">
<li> Purpose: home defense / range</li>
<li> Priority: reliability first</li>
<li> Next upgrades: optic, sling, light</li>
</ul>
</div>
</aside>
</div>
</main>
);
}
+293 -178
View File
@@ -1,103 +1,182 @@
"use client"; "use client";
import { useMemo, useState } from "react"; /**
* app/builds/page.tsx
* Community Builds feed (public)
*
* Dev Notes:
* - We are moving to Option B:
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
*
* Backend (target contract):
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
*/
import { useEffect, useMemo, useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA"; type BuildClass = "Rifle" | "Pistol" | "NFA";
type Caliber = "5.56" | "7.62" | "9mm" | ".300 BLK" | "12ga";
/**
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
* We can tighten once the controlled vocab is finalized.
*/
type Caliber = string;
/**
* This matches what the UI needs (card format).
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
*/
type BuildCard = { type BuildCard = {
id: string; id: string; // we use uuid here (public identifier)
title: string; title: string;
slug: string; slug: string; // can be uuid for now; keep property so UI does not change
creator: string; creator: string; // placeholder until user profiles are real
caliber: Caliber; caliber: Caliber;
buildClass: BuildClass; buildClass: BuildClass;
price: number; price: number; // optional server-side later; for now allow 0 fallback
votes: number; votes: number;
tags: string[]; tags: string[];
coverImageUrl?: string | null;
}; };
const DUMMY_BUILDS: BuildCard[] = [ type BuildFeedCardDto = {
{ uuid: string;
id: "1", title?: string | null;
title: "Duty-Grade 12.5\" AR-15", slug?: string | null; // optional (we can generate from title later)
slug: "duty-12-5-ar15", creator?: string | null; // optional
creator: "quietpro_01", caliber?: string | null;
caliber: "5.56", buildClass?: BuildClass | null;
buildClass: "Rifle", price?: number | null;
price: 2450, votes?: number | null;
votes: 128, tags?: string[] | null;
tags: ["Duty", "NV-Ready", "LPVO"], coverImageUrl?: string | null;
}, };
{
id: "2",
title: "Home Defense PCC",
slug: "home-defense-pcc",
creator: "nerdgunner",
caliber: "9mm",
buildClass: "Pistol",
price: 1650,
votes: 74,
tags: ["Home Defense", "Red Dot", "Suppressor-Ready"],
},
{
id: "3",
title: "Short King .300 BLK SBR",
slug: "short-king-300blk-sbr",
creator: "subsonic_six",
caliber: ".300 BLK",
buildClass: "NFA",
price: 3125,
votes: 201,
tags: ["SBR", "Suppressed", "Night Work"],
},
{
id: "4",
title: "Budget 16\" Training Rifle",
slug: "budget-16-training-rifle",
creator: "range_rat",
caliber: "5.56",
buildClass: "Rifle",
price: 975,
votes: 53,
tags: ["Budget", "Trainer", "Recce-ish"],
},
];
const CALIBER_FILTERS: (Caliber | "all")[] = [ const API_BASE_URL =
"all", process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
"5.56",
"7.62",
"9mm",
".300 BLK",
"12ga",
];
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"]; const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [ const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity }, { id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000 }, { id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000, max: 2000 }, { id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000, max: 3000 }, { id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000, max: Infinity }, { id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
]; ];
function safeArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
}
function fallbackSlug(dto: BuildFeedCardDto) {
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
return dto.slug?.trim() || dto.uuid;
}
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
return {
id: dto.uuid,
title,
slug: fallbackSlug(dto),
// Until we wire real users: keep a placeholder creator string.
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
caliber: (dto.caliber ?? "—").trim() || "—",
// Default to Rifle for display if missing; backend should set this via profile.
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
// Price is optional (we can compute later from BuildItems + offers).
price: typeof dto.price === "number" ? dto.price : 0,
votes: typeof dto.votes === "number" ? dto.votes : 0,
tags: safeArray(dto.tags),
coverImageUrl: dto.coverImageUrl ?? null,
};
}
export default function BuildsPage() { export default function BuildsPage() {
// --- Remote data state ---
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [builds, setBuilds] = useState<BuildCard[]>([]);
// --- UI state (filters/votes) ---
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all"); const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all"); const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all"); const [priceFilterId, setPriceFilterId] = useState<string>("all");
const [votes, setVotes] = useState<Record<string, number>>(
Object.fromEntries(DUMMY_BUILDS.map((b) => [b.id, b.votes])),
);
const activePriceFilter = PRICE_FILTERS.find( // Local vote state for optimistic UI
(p) => p.id === priceFilterId, const [votes, setVotes] = useState<Record<string, number>>({});
) ?? PRICE_FILTERS[0];
const activePriceFilter =
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
// --- Fetch public builds feed ---
useEffect(() => {
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load builds (${res.status})`);
}
const data = (await res.json()) as BuildFeedCardDto[];
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
if (cancelled) return;
setBuilds(cards);
// Initialize vote UI from payload totals
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, []);
// Build the caliber filters dynamically from data, but keep "all" first.
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => { const filteredBuilds = useMemo(() => {
return DUMMY_BUILDS.filter((build) => { return builds.filter((build) => {
const matchesCaliber = const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter; caliberFilter === "all" || build.caliber === caliberFilter;
@@ -110,14 +189,16 @@ export default function BuildsPage() {
return matchesCaliber && matchesClass && matchesPrice; return matchesCaliber && matchesClass && matchesPrice;
}); });
}, [caliberFilter, classFilter, activePriceFilter]); }, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = (id: string, delta: 1 | -1) => { const handleVote = useCallback((id: string, delta: 1 | -1) => {
// MVP: optimistic local vote only.
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
setVotes((prev) => ({ setVotes((prev) => ({
...prev, ...prev,
[id]: (prev[id] ?? 0) + delta, [id]: (prev[id] ?? 0) + delta,
})); }));
}; }, []);
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
@@ -129,18 +210,19 @@ export default function BuildsPage() {
Battl Builder · Build Book Battl Builder · Build Book
</p> </p>
<h1 <h1
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400 bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]" text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
> >
Community Builds Community Builds
</h1> </h1>
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl"> <p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
Browse community rifle builds, vote them up or down, and steal Browse community builds, vote them up or down, and steal good
good ideas without shame. This is placeholder content for now ideas without shame. This feed is backed by public builds
real accounts, comments, and saved builds will wire in later. (isPublic=true).
</p> </p>
</div> </div>
<div className="flex gap-2 md:gap-3"> <div className="flex gap-2 md:gap-3">
<Link <Link
href="/builder" href="/builder"
@@ -148,15 +230,35 @@ export default function BuildsPage() {
> >
Open Builder Open Builder
</Link> </Link>
<button
type="button" {/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
<Link
href="/vault"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors" className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
> >
+ Submit Build (Coming Soon) + Submit Build
</button> </Link>
</div> </div>
</header> </header>
{/* Loading / Error */}
{loading && (
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */} {/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4"> <section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -165,21 +267,19 @@ export default function BuildsPage() {
Filter Builds Filter Builds
</h2> </h2>
<p className="text-xs text-zinc-500 mt-1"> <p className="text-xs text-zinc-500 mt-1">
Filter by caliber, platform class, and ballpark price range. Filter by caliber, class, and price range. (Client-side for
All logic is client-side placeholder until the real feed is wired MVP.)
into the backend.
</p> </p>
</div> </div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
Showing{" "} Showing{" "}
<span className="text-zinc-200 font-medium"> <span className="text-zinc-200 font-medium">
{filteredBuilds.length} {filteredBuilds.length}
</span>{" "} </span>{" "}
of{" "} of{" "}
<span className="text-zinc-200 font-medium"> <span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
{DUMMY_BUILDS.length} builds
</span>{" "}
demo builds
</div> </div>
</div> </div>
@@ -219,9 +319,7 @@ export default function BuildsPage() {
<button <button
key={cls} key={cls}
type="button" type="button"
onClick={() => onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
setClassFilter(cls === "all" ? "all" : cls)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${ className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200" ? "border-amber-400/70 bg-amber-400/10 text-amber-200"
@@ -262,100 +360,117 @@ export default function BuildsPage() {
{/* Build list */} {/* Build list */}
<section> <section>
<div className="space-y-4"> <div className="space-y-4">
{filteredBuilds.map((build) => ( {filteredBuilds.map((build) => {
<article const dollars = Math.floor((build.price ?? 0) / 100);
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
>
</button>
</div>
{/* Placeholder thumbnail */} return (
<div className="hidden sm:block"> <article
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40"> key={build.id}
{/* eslint-disable-next-line @next/next/no-img-element */} className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
<img >
src="https://placehold.co/320x200/png?text=Build+Photo" {/* Votes column */}
alt={`${build.title} placeholder`} <div className="flex flex-col items-center justify-center gap-1">
className="h-full w-full object-cover" <button
/> type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div> </div>
</div>
{/* Main content */} {/* Thumbnail */}
<div className="flex-1"> <div className="hidden sm:block">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between"> <div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
<div> {/* eslint-disable-next-line @next/next/no-img-element */}
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1"> <img
{build.buildClass} · {build.caliber} src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div> </div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link <div className="text-right mt-1 md:mt-0">
href={`/builds/${build.slug}`} <div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
className="hover:text-amber-200 transition-colors" Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
> >
{build.title} {tag}
</Link> </span>
</h3> ))}
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Placeholder build listing details, parts list, and
comments will live on the build detail page later.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
${build.price.toLocaleString()}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2"> <span className="ml-auto text-[0.7rem] text-zinc-500">
{build.tags.map((tag) => ( Comments + save coming soon.
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span> </span>
))} </div>
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments, save, and share coming soon.
</span>
</div> </div>
</div> </article>
</article> );
))} })}
{filteredBuilds.length === 0 && ( {!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500"> <div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet. Once the real feed is wired No builds match those filters yet.
up, this will update live as the community posts rifles, pistols,
and NFA builds.
</div> </div>
)} )}
</div> </div>
@@ -363,4 +478,4 @@ export default function BuildsPage() {
</div> </div>
</main> </main>
); );
} }
+13 -2
View File
@@ -1,6 +1,8 @@
// app/layout.tsx // app/layout.tsx
import "./globals.css"; import "./globals.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Script from "next/script";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
import { Banner } from "@/components/Banner"; import { Banner } from "@/components/Banner";
@@ -10,6 +12,12 @@ export const metadata = {
template: "%s — Battl Builders", template: "%s — Battl Builders",
}, },
description: "Build rifles smarter, not harder.", description: "Build rifles smarter, not harder.",
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
},
}; };
const themeScript = ` const themeScript = `
@@ -25,13 +33,16 @@ const themeScript = `
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning className="dark">
<head> <head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head> </head>
<body className="bg-white text-zinc-950 dark:bg-black dark:text-zinc-50"> <body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
{" "} {" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
strategy="lazyOnload"
/>
<AuthProvider> <AuthProvider>
<Banner /> <Banner />
{children} {children}
+150 -49
View File
@@ -1,12 +1,15 @@
// app/login/page.tsx // app/login/page.tsx
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { Button, Field, Input } from "@/components/ui/form"; import { Button, Field, Input } from "@/components/ui/form";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -14,9 +17,18 @@ export default function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/builder"; // Magic link UI state (separate from password login)
const [magicLoading, setMagicLoading] = useState(false);
const [magicMessage, setMagicMessage] = useState<string | null>(null);
const [magicError, setMagicError] = useState<string | null>(null);
const next = useMemo(
() => searchParams.get("next") || "/builder",
[searchParams]
);
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -26,62 +38,151 @@ export default function LoginPage() {
await login({ email, password }); await login({ email, password });
router.push(next); router.push(next);
} catch (err: any) { } catch (err: any) {
setError(err.message ?? "Failed to log in"); setError(err?.message ?? "Failed to log in");
}
}
async function handleMagicLinkRequest() {
setMagicMessage(null);
setMagicError(null);
const normalizedEmail = email.trim();
if (!normalizedEmail) {
setMagicError("Please enter your email above first.");
return;
}
setMagicLoading(true);
try {
// ✅ This endpoint exists in your AuthController:
// POST /api/auth/beta/signup { email, useCase }
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: normalizedEmail,
useCase: "login_magic",
}),
});
// Soft-fail (avoid email enumeration)
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Magic link request failed:", res.status, text);
}
setMagicMessage(
"If that email is eligible, youll get a sign-in link shortly ✨"
);
} catch (e) {
console.error(e);
// Soft-fail
setMagicMessage(
"If that email is eligible, youll get a sign-in link shortly ✨"
);
} finally {
setMagicLoading(false);
} }
} }
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto flex max-w-md flex-col px-4 py-10"> <div className="mx-auto flex max-w-md flex-col px-4 py-10">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
Log In to <span className="text-amber-300">The Armory</span> Log In to <span className="text-amber-300">The Builder</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400"> <p className="mt-2 text-sm text-zinc-400">
Use your beta credentials to get back to your saved builds. Use your beta credentials to get back to your saved builds.
</p> </p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4"> {/* Password Login */}
{error && ( <form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200"> {error && (
{error} <div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
</div> {error}
)} </div>
)}
<Field label="Email" htmlFor="email"> <Field label="Email" htmlFor="email">
<Input <Input
id="email" id="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
</Field> </Field>
<Field label="Password" htmlFor="password"> <Field label="Password" htmlFor="password">
<Input <Input
id="password" id="password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
</Field> </Field>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading ? "Signing in…" : "Log In"} {loading ? "Signing in…" : "Log In"}
</Button> </Button>
</form> </form>
<p className="mt-4 text-xs text-zinc-500"> {/* Divider */}
New here?{" "} <div className="mt-8 flex items-center gap-3">
<Link href="/register" className="text-amber-300 hover:text-amber-200"> <div className="h-px flex-1 bg-white/10" />
Join the beta <span className="text-xs text-zinc-500">or</span>
</Link> <div className="h-px flex-1 bg-white/10" />
. </div>
{/* Magic Link Request */}
<div className="mt-6 space-y-3">
<div>
<h2 className="text-sm font-medium text-zinc-200">
Already a beta user?
</h2>
<p className="mt-1 text-xs text-zinc-500">
Request a one-time sign-in link (no password needed).
</p>
</div>
{magicError && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{magicError}
</div>
)}
{magicMessage && (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
{magicMessage}
</div>
)}
<Button
type="button"
disabled={magicLoading}
onClick={handleMagicLinkRequest}
>
{magicLoading ? "Sending link…" : "Email me a sign-in link"}
</Button>
<p className="text-[11px] text-zinc-500">
Tip: check spam/promotions. Links expire.
</p> </p>
</div> </div>
</main>
<p className="mt-8 text-xs text-zinc-500">
New here?{" "}
<Link href="/register" className="text-amber-300 hover:text-amber-200">
Join the beta
</Link>
.
</p>
</div>
</main>
); );
} }
+38 -12
View File
@@ -13,6 +13,7 @@ export default function HomePage() {
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!email) { if (!email) {
setMessage("Drop an email in first, operator."); setMessage("Drop an email in first, operator.");
setStatus("error"); setStatus("error");
@@ -30,11 +31,12 @@ export default function HomePage() {
}); });
if (!res.ok) { if (!res.ok) {
throw new Error("Failed to save your signup."); const txt = await res.text().catch(() => "");
throw new Error(txt || "Failed to save your signup.");
} }
setStatus("success"); setStatus("success");
setMessage("Youre locked in. Watch your inbox."); setMessage("Youre on the list. Invites drop soon — well email your access link when its go-time.");
setEmail(""); setEmail("");
setUseCase(""); setUseCase("");
} catch (err) { } catch (err) {
@@ -98,7 +100,7 @@ export default function HomePage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center"> <div className="flex items-center">
<Image <Image
src="/battl/battl-logo-mark-2.svg" src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo" alt="Battl Builders Logo"
width={260} // adjust to taste width={260} // adjust to taste
height={48} // adjust to taste height={48} // adjust to taste
@@ -160,7 +162,8 @@ export default function HomePage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@gearjunkie.com" placeholder="you@gearjunkie.com"
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2" disabled={status === "loading" || status === "success"}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
@@ -178,17 +181,22 @@ export default function HomePage() {
value={useCase} value={useCase}
onChange={(e) => setUseCase(e.target.value)} onChange={(e) => setUseCase(e.target.value)}
rows={3} rows={3}
disabled={status === "loading" || status === "success"}
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..." placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..."
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2" className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={status === "loading"} disabled={status === "loading" || status === "success"}
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70" className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
> >
{status === "loading" ? "Enlisting…" : "Get Beta Access"} {status === "loading"
? "Enlisting…"
: status === "success"
? "On the List ✅"
: "Get Beta Access"}
</button> </button>
{message && ( {message && (
@@ -205,10 +213,25 @@ export default function HomePage() {
</p> </p>
)} )}
<p className="pt-1 text-[10px] leading-relaxed text-zinc-500"> {status === "success" && (
You can one-click unsubscribe any time with one-click. No data <button
games, no surprise newsletters. type="button"
</p> onClick={() => {
setStatus("idle");
setMessage(null);
}}
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-900"
>
Submit another email
</button>
)}
<div className="mt-6 text-center text-[11px] text-zinc-500">
Already invited?{" "}
<a href="/login" className="underline hover:text-zinc-300">
Log in
</a>
</div>
</form> </form>
</div> </div>
</section> </section>
@@ -216,7 +239,10 @@ export default function HomePage() {
{/* Footer strip */} {/* Footer strip */}
<footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500"> <footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<span>© {new Date().getFullYear()} BATTL BUILDERS<span className="align-super text-[0.9em] ml-0.5"></span></span> <span>
© {new Date().getFullYear()} BATTL BUILDERS
<span className="align-super text-[0.9em] ml-0.5"></span>
</span>
<span className="text-zinc-600"> <span className="text-zinc-600">
v0.1 Import engine online Builder UI in progress v0.1 Import engine online Builder UI in progress
</span> </span>
-173
View File
@@ -1,173 +0,0 @@
// app/vault/page.tsx
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
type BuildDto = {
id: string;
uuid: string; // UUID string
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d;
return dt.toLocaleString();
}
export default function VaultPage() {
const router = useRouter();
const { user, loading, getAuthHeaders } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const authed = !!user;
useEffect(() => {
if (!loading && !authed) {
router.replace("/login");
}
}, [loading, authed, router]);
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
useEffect(() => {
if (loading || !authed) return;
let cancelled = false;
(async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, {
method: "GET",
headers,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const data = (await res.json()) as BuildDto[];
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds");
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [loading, authed, headers]);
if (loading) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
}
// If not authed, we already redirected; render nothing.
if (!authed) return null;
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Vault</h1>
<p className="text-sm opacity-70">
Your saved builds. Load one into the builder or publish it later.
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/builder"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
>
New Build
</Link>
<Link
href="/account"
className="text-sm opacity-80 hover:underline"
>
Account
</Link>
</div>
</div>
{error ? (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error}
</div>
) : null}
<div className="rounded-xl border border-white/10 bg-white/5">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="text-sm font-medium">My Builds</div>
<div className="text-xs opacity-70">
{busy ? "Refreshing…" : `${builds.length} total`}
</div>
</div>
{builds.length === 0 ? (
<div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save to Vault.
</div>
) : (
<ul className="divide-y divide-white/10">
{builds.map((b) => (
<li key={b.uuid} className="p-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="font-medium truncate">{b.title}</div>
<div className="mt-1 text-xs opacity-70">
Updated: {fmt(b.updatedAt)} UUID:{" "}
<span className="font-mono">{b.uuid}</span>
</div>
{b.description ? (
<div className="mt-2 text-sm opacity-80 line-clamp-2">
{b.description}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Placeholder: wire this to "load into builder" next */}
<Link
href={`/builder?load=${b.uuid}`}
// future href to support platform
// href={`/builder?platform=${encodeURIComponent(platform)}&load=${b.uuid}`}
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
>
Open
</Link>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+31 -59
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import Link from "next/link";
import { import {
LayoutDashboard, LayoutDashboard,
Download, Download,
@@ -10,70 +11,41 @@ import {
Users, Users,
Settings, Settings,
LucideMail, LucideMail,
Wand2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
export type AdminNavItem = {
label: string;
href: string;
icon: React.ReactNode;
};
type AdminLeftNavigationProps = { type AdminLeftNavigationProps = {
collapsed: boolean; collapsed: boolean;
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
items?: AdminNavItem[]; // ✅ NEW
}; };
const navItems = [ const defaultNavItems: AdminNavItem[] = [
{ { label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
label: "Dashboard", { label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
href: "/admin", { label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
icon: <LayoutDashboard className="h-4 w-4" />, { label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
}, { label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
{ { label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
label: "Imports", { label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
href: "/admin/import-status", { label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
icon: <Download className="h-4 w-4" />, { label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
}, { label: "Manage Emails", href: "/admin/email/manage", icon: <LucideMail className="h-4 w-4" /> },
{ { label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
label: "Mappings", { label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" />,
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
{
label: "Manage Emails",
href: "/admin/email/manage",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
}, },
]; ];
export default function AdminLeftNavigation({ export default function AdminLeftNavigation({
collapsed, collapsed,
onToggleCollapsed, onToggleCollapsed,
items = defaultNavItems, // ✅ NEW default
}: AdminLeftNavigationProps) { }: AdminLeftNavigationProps) {
return ( return (
<aside <aside
@@ -98,7 +70,9 @@ export default function AdminLeftNavigation({
{!collapsed && ( {!collapsed && (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500"> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
<Link href={"/"} title={"Back to Battl Dashboard"} >Battl Builders</Link> <Link href="/" title="Back to Battl Dashboard">
Battl Builders
</Link>
</p> </p>
<p className="text-[10px] text-zinc-600">Admin Command</p> <p className="text-[10px] text-zinc-600">Admin Command</p>
</div> </div>
@@ -106,8 +80,8 @@ export default function AdminLeftNavigation({
</div> </div>
<nav className="flex-1 space-y-1 text-sm"> <nav className="flex-1 space-y-1 text-sm">
{navItems.map((item) => ( {items.map((item) => (
<a <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300" className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
@@ -117,15 +91,13 @@ export default function AdminLeftNavigation({
{!collapsed && <span>{item.label}</span>} {!collapsed && <span>{item.label}</span>}
</div> </div>
{!collapsed && <span className="text-[10px] text-zinc-600"></span>} {!collapsed && <span className="text-[10px] text-zinc-600"></span>}
</a> </Link>
))} ))}
</nav> </nav>
{!collapsed && ( {!collapsed && (
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600"> <div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"> <p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
Status
</p>
<p className="mt-1"> <p className="mt-1">
Import engine: <span className="text-emerald-400">online</span> Import engine: <span className="text-emerald-400">online</span>
</p> </p>
@@ -134,4 +106,4 @@ export default function AdminLeftNavigation({
)} )}
</aside> </aside>
); );
} }
+1 -1
View File
@@ -11,7 +11,7 @@ export function Banner() {
Early Access Beta Early Access Beta
</span> </span>
<span className="hidden text-amber-100/90 md:inline"> <span className="hidden text-amber-100/90 md:inline">
You&apos;re using an early-access prototype of The Armory. Data, You&apos;re using an early-access prototype of the Builder. Data,
pricing, and available parts are still evolving. pricing, and available parts are still evolving.
</span> </span>
</div> </div>
+10 -3
View File
@@ -57,6 +57,8 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
const renderDropdown = (label: string, items: Category[]) => { const renderDropdown = (label: string, items: Category[]) => {
if (!items.length) return null; if (!items.length) return null;
const platform = searchParams.get("platform");
return ( return (
<div className="relative group"> <div className="relative group">
{/* Dropdown trigger */} {/* Dropdown trigger */}
@@ -80,7 +82,10 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
return ( return (
<li key={cat.id}> <li key={cat.id}>
<Link <Link
href={`${baseHref}/${cat.id}`} href={{
pathname: `${baseHref}/${cat.id}`,
query: platform ? { platform } : {},
}}
className={`block w-full px-3 py-1.5 transition-colors ${ className={`block w-full px-3 py-1.5 transition-colors ${
isActive isActive
? "bg-neutral-800 text-white" ? "bg-neutral-800 text-white"
@@ -162,7 +167,9 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
{/* "Viewing" indicator (only shows when a category is active) */} {/* "Viewing" indicator (only shows when a category is active) */}
{currentCategory && ( {currentCategory && (
<div className="flex items-center gap-2 text-neutral-400 text-xs"> <div className="flex items-center gap-2 text-neutral-400 text-xs">
<span className="uppercase tracking-wide text-[10px]">Viewing</span> <span className="uppercase tracking-wide text-[10px]">
Viewing
</span>
<span className="font-medium text-neutral-100"> <span className="font-medium text-neutral-100">
{CATEGORIES.find((c) => c.id === currentCategory)?.name ?? {CATEGORIES.find((c) => c.id === currentCategory)?.name ??
@@ -188,4 +195,4 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
); );
} }
export default BuilderNav; export default BuilderNav;
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { PriceHistoryPoint } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data"; import type { PriceHistoryPoint } from "@/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data";
interface PricingHistoryGraphProps { interface PricingHistoryGraphProps {
data: PriceHistoryPoint[]; data: PriceHistoryPoint[];
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { RetailerOffer } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data"; import type { RetailerOffer } from "@/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data";
interface RetailersListProps { interface RetailersListProps {
retailers: RetailerOffer[]; retailers: RetailerOffer[];
+4 -1
View File
@@ -24,11 +24,14 @@ export default function ThemeToggle() {
document.documentElement.classList.toggle("dark", next === "dark"); document.documentElement.classList.toggle("dark", next === "dark");
} }
/**
* @TODO, the toggle doesn't look right, need to either fix or remove the toggle.
*/
return ( return (
<button <button
type="button" type="button"
onClick={toggle} onClick={toggle}
aria-label="Toggle theme" aria-label="Toggle theme" title={`Toggle to ${theme === 'dark' ? 'light' : 'dark'}`}
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 transition-colors className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 transition-colors
dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-200 dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-200
border-zinc-300 bg-white text-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800" border-zinc-300 bg-white text-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+7 -3
View File
@@ -11,9 +11,11 @@ import ThemeToggle from "@/components/ThemeToggle";
function NavLink({ function NavLink({
href, href,
children, children,
title
}: { }: {
href: string; href: string;
children: React.ReactNode; children: React.ReactNode;
title?: string;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const active = pathname === href; const active = pathname === href;
@@ -21,6 +23,7 @@ function NavLink({
return ( return (
<Link <Link
href={href} href={href}
title={title}
className={[ className={[
"text-xs font-medium transition-colors", "text-xs font-medium transition-colors",
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100", active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
@@ -44,7 +47,7 @@ export function TopNav() {
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400" className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
> >
<Image <Image
src="/battl/battl-logo-mark-2.svg" src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo" alt="Battl Builders Logo"
width={260} width={260}
height={48} height={48}
@@ -60,7 +63,7 @@ export function TopNav() {
<span className="text-xs text-zinc-500">Checking session</span> <span className="text-xs text-zinc-500">Checking session</span>
) : isAuthenticated ? ( ) : isAuthenticated ? (
<> <>
<NavLink href="/vault">Vault</NavLink> <NavLink href="/vault" title={"Your builds"}>Vault</NavLink>
{/* Email/displayName links to Account */} {/* Email/displayName links to Account */}
<Link <Link
@@ -95,4 +98,5 @@ export function TopNav() {
</div> </div>
</header> </header>
); );
} }
export default TopNav;
+157 -175
View File
@@ -1,8 +1,3 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
/** /**
* PartsBrowseClient * PartsBrowseClient
* ----------------------------------------------------------------------------- * -----------------------------------------------------------------------------
@@ -13,8 +8,11 @@ import { usePathname } from "next/navigation";
* - layout + list/card views * - layout + list/card views
*/ */
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Filters from "@/components/parts/Filters"; import Filters from "@/components/parts/Filters";
import SortBar from "@/components/parts/SortBar"; import SortBar from "@/components/parts/SortBar";
@@ -49,7 +47,7 @@ type UiPart = {
brand: string; brand: string;
platform: string; platform: string;
partRole: string; partRole: string;
price: number; // normalized price: number;
imageUrl?: string; imageUrl?: string;
buyUrl?: string; buyUrl?: string;
inStock?: boolean; inStock?: boolean;
@@ -57,6 +55,7 @@ type UiPart = {
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
function normalizeId(id: string | number) { function normalizeId(id: string | number) {
@@ -92,8 +91,13 @@ export default function PartsBrowseClient(props: {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname(); const pathname = usePathname();
// ✅ Builder-mode = /parts/p/... (detail routes)
const isBuilderMode = pathname.startsWith("/parts/p/");
// ✅ Respect prop override first, else read query, else fallback
const effectivePlatform = const effectivePlatform =
props.platform ?? searchParams.get("platform") ?? "AR-15"; props.platform ?? searchParams.get("platform") ?? "AR-15";
const partRole = props.partRole; const partRole = props.partRole;
const [viewMode, setViewMode] = useState<ViewMode>("list"); const [viewMode, setViewMode] = useState<ViewMode>("list");
@@ -103,7 +107,7 @@ export default function PartsBrowseClient(props: {
const [brandFilter, setBrandFilter] = useState<string[]>([]); const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance"); const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState("");
const [priceRange, setPriceRange] = useState<{ const [priceRange, setPriceRange] = useState<{
min: number | null; min: number | null;
max: number | null; max: number | null;
@@ -114,6 +118,9 @@ export default function PartsBrowseClient(props: {
const [inStockOnly, setInStockOnly] = useState(false); const [inStockOnly, setInStockOnly] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
// ----------------------------
// Fetch parts
// ----------------------------
useEffect(() => { useEffect(() => {
if (!partRole) return; if (!partRole) return;
@@ -128,26 +135,28 @@ export default function PartsBrowseClient(props: {
search.set("platform", effectivePlatform); search.set("platform", effectivePlatform);
search.append("partRoles", partRole); search.append("partRoles", partRole);
const url = `${API_BASE_URL}/api/v1/products?${search.toString()}`; const res = await fetch(
const res = await fetch(url, { signal: controller.signal }); `${API_BASE_URL}/api/v1/products?${search.toString()}`,
{ signal: controller.signal }
);
if (!res.ok) throw new Error(`Failed to load products (${res.status})`); if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
const data: GunbuilderProductFromApi[] = await res.json(); const data: GunbuilderProductFromApi[] = await res.json();
const normalized: UiPart[] = data.map((p) => ({ setParts(
id: normalizeId(p.id), data.map((p) => ({
name: p.name, id: normalizeId(p.id),
brand: p.brand, name: p.name,
platform: p.platform, brand: p.brand,
partRole: p.partRole, platform: p.platform,
price: p.price ?? 0, partRole: p.partRole,
imageUrl: p.mainImageUrl ?? undefined, price: p.price ?? 0,
buyUrl: p.buyUrl ?? undefined, imageUrl: p.mainImageUrl ?? undefined,
inStock: p.inStock ?? true, buyUrl: p.buyUrl ?? undefined,
})); inStock: p.inStock ?? true,
}))
setParts(normalized); );
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") return; if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products"); setError(err?.message ?? "Failed to load products");
@@ -160,6 +169,7 @@ export default function PartsBrowseClient(props: {
return () => controller.abort(); return () => controller.abort();
}, [partRole, effectivePlatform]); }, [partRole, effectivePlatform]);
// Reset pagination on filters
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [ }, [
@@ -172,11 +182,14 @@ export default function PartsBrowseClient(props: {
inStockOnly, inStockOnly,
]); ]);
// Reset scroll on route change
useEffect(() => { useEffect(() => {
// Reset scroll position whenever we land on or change parts routes window.scrollTo({ top: 0, behavior: "auto" });
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, [pathname]); }, [pathname]);
// ----------------------------
// Derived values
// ----------------------------
const availableBrands = useMemo( const availableBrands = useMemo(
() => () =>
Array.from(new Set(parts.map((p) => p.brand))) Array.from(new Set(parts.map((p) => p.brand)))
@@ -186,34 +199,30 @@ export default function PartsBrowseClient(props: {
); );
const priceBounds = useMemo(() => { const priceBounds = useMemo(() => {
if (parts.length === 0) if (!parts.length)
return { min: null as number | null, max: null as number | null }; return { min: null as number | null, max: null as number | null };
return {
let min = Number.POSITIVE_INFINITY; min: Math.min(...parts.map((p) => p.price)),
let max = Number.NEGATIVE_INFINITY; max: Math.max(...parts.map((p) => p.price)),
};
for (const p of parts) {
const price = p.price ?? 0;
if (price < min) min = price;
if (price > max) max = price;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return { min: null as number | null, max: null as number | null };
}
return { min, max };
}, [parts]); }, [parts]);
// Keep priceRange clamped to bounds when bounds change
useEffect(() => { useEffect(() => {
if (priceBounds.min == null || priceBounds.max == null) return; const minBound = priceBounds.min;
const maxBound = priceBounds.max;
if (minBound == null || maxBound == null) return;
setPriceRange((prev) => { setPriceRange((prev) => {
const min = prev.min ?? priceBounds.min!; const nextMin =
const max = prev.max ?? priceBounds.max!; prev.min == null ? minBound : Math.max(minBound, prev.min);
const nextMax =
prev.max == null ? maxBound : Math.min(maxBound, prev.max);
// Ensure min never exceeds max (in case user typed weird values)
return { return {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)), min: Math.min(nextMin, nextMax),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)), max: Math.max(nextMin, nextMax),
}; };
}); });
}, [priceBounds.min, priceBounds.max]); }, [priceBounds.min, priceBounds.max]);
@@ -221,30 +230,22 @@ export default function PartsBrowseClient(props: {
const filteredParts = useMemo(() => { const filteredParts = useMemo(() => {
let result = [...parts]; let result = [...parts];
const effectiveMin = priceRange.min ?? priceBounds.min; if (priceRange.min != null)
const effectiveMax = priceRange.max ?? priceBounds.max; result = result.filter((p) => p.price >= priceRange.min!);
if (effectiveMin != null && effectiveMax != null) { if (priceRange.max != null)
result = result.filter( result = result.filter((p) => p.price <= priceRange.max!);
(p) => p.price >= effectiveMin && p.price <= effectiveMax
);
}
if (brandFilter.length > 0) { if (brandFilter.length)
result = result.filter((p) => brandFilter.includes(p.brand)); result = result.filter((p) => brandFilter.includes(p.brand));
}
if (inStockOnly) { if (inStockOnly) result = result.filter((p) => p.inStock ?? true);
result = result.filter((p) => p.inStock ?? true);
}
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.toLowerCase().trim();
if (q) { if (q)
result = result.filter((p) => { result = result.filter(
const name = (p.name ?? "").toLowerCase(); (p) =>
const brand = (p.brand ?? "").toLowerCase(); p.name.toLowerCase().includes(q) || p.brand.toLowerCase().includes(q)
return name.includes(q) || brand.includes(q); );
});
}
switch (sortBy) { switch (sortBy) {
case "price-asc": case "price-asc":
@@ -262,47 +263,25 @@ export default function PartsBrowseClient(props: {
} }
return result; return result;
}, [ }, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
parts,
brandFilter,
sortBy,
searchQuery,
priceRange,
priceBounds.min,
priceBounds.max,
inStockOnly,
]);
const totalPages = useMemo( const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE));
() => const paginatedParts = filteredParts.slice(
filteredParts.length === 0 (currentPage - 1) * PAGE_SIZE,
? 1 currentPage * PAGE_SIZE
: Math.ceil(filteredParts.length / PAGE_SIZE),
[filteredParts.length]
); );
const paginatedParts = useMemo(() => { const visibleRange = {
if (filteredParts.length === 0) return []; start: filteredParts.length ? (currentPage - 1) * PAGE_SIZE + 1 : 0,
const startIndex = (currentPage - 1) * PAGE_SIZE; end: Math.min(currentPage * PAGE_SIZE, filteredParts.length),
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE); };
}, [filteredParts, currentPage]);
const visibleRange = useMemo(() => {
if (filteredParts.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(
start + paginatedParts.length - 1,
filteredParts.length
);
return { start, end };
}, [filteredParts.length, currentPage, paginatedParts.length]);
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`; const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
const headingSubtitle = const headingSubtitle = props.subtitle ?? "Browse available parts.";
props.subtitle ??
"Browse available parts pulled from your Ballistic backend.";
// ✅ Add-to-build handler: deep-links into builders existing ?select= logic // ----------------------------
// Add → Builder handoff
// ----------------------------
const handleAddToBuild = (p: UiPart) => { const handleAddToBuild = (p: UiPart) => {
const normalizedRole = normalizePartRole(partRole); const normalizedRole = normalizePartRole(partRole);
const categoryId: CategoryId | null = const categoryId: CategoryId | null =
@@ -327,80 +306,88 @@ export default function PartsBrowseClient(props: {
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10"> <div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
<header className="mb-6"> <header className="mb-6 flex justify-between gap-4">
<div className="flex items-start justify-between gap-4"> <div>
<div> <p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500"> Battl Builders
Battl Builders </p>
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight"> <h1 className="mt-1 text-2xl md:text-3xl font-semibold">
{headingTitle}{" "} {headingTitle}{" "}
<span className="text-amber-300">{effectivePlatform}</span> <span className="text-amber-300">{effectivePlatform}</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl"> <p className="mt-2 text-sm text-zinc-400 max-w-xl">
{headingSubtitle} {headingSubtitle}
</p> </p>
<div className="mt-3"> {!isBuilderMode && (
<div className="mt-4 flex flex-wrap items-center gap-3">
<PlatformSwitcher <PlatformSwitcher
currentPlatform={effectivePlatform} currentPlatform={effectivePlatform}
partRole={partRole} partRole={partRole}
preserveQuery preserveQuery
mode="browse"
/> />
</div>
</div>
{!loading && !error && (
<div className="flex items-center gap-2">
<Link <Link
href={{ href={`/builder?platform=${encodeURIComponent(
pathname: "/builder", effectivePlatform
query: effectivePlatform )}`}
? { platform: effectivePlatform } className="inline-flex items-center rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
: {},
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800"
> >
Back to Build Start New Build
</Link> </Link>
{parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
>
List
</button>
</div>
)}
</div> </div>
)} )}
</div> </div>
{isBuilderMode && (
<div className="flex items-center gap-2">
<Link
href={`/builder?platform=${encodeURIComponent(
effectivePlatform
)}`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold hover:bg-zinc-800"
>
Back to Build
</Link>
{/* Optional: view toggle in builder-mode too */}
{parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
>
List
</button>
</div>
)}
</div>
)}
</header> </header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4"> <section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4">
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col md:flex-row gap-4">
{!loading && !error && parts.length > 0 && ( {parts.length > 0 && (
<aside className="w-full md:w-64 shrink-0"> <aside className="w-full md:w-64">
<Filters <Filters
partRoleLabel={partRole} partRoleLabel={partRole}
availableBrands={availableBrands} availableBrands={availableBrands}
@@ -416,7 +403,7 @@ export default function PartsBrowseClient(props: {
)} )}
<div className="flex-1"> <div className="flex-1">
{!loading && !error && parts.length > 0 && ( {!loading && !error && (
<SortBar <SortBar
visibleRange={visibleRange} visibleRange={visibleRange}
totalCount={filteredParts.length} totalCount={filteredParts.length}
@@ -432,9 +419,7 @@ export default function PartsBrowseClient(props: {
Loading Loading
</p> </p>
) : error ? ( ) : error ? (
<p className="py-8 text-center text-sm text-red-400"> <p className="py-8 text-center text-sm text-red-400">{error}</p>
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? ( ) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500"> <p className="py-8 text-center text-sm text-zinc-500">
No parts found for this role yet. No parts found for this role yet.
@@ -451,19 +436,16 @@ export default function PartsBrowseClient(props: {
/> />
)} )}
{!loading && {totalPages > 1 && (
!error && <Pagination
filteredParts.length > 0 && currentPage={currentPage}
totalPages > 1 && ( totalPages={totalPages}
<Pagination onPrev={() => setCurrentPage((p) => Math.max(1, p - 1))}
currentPage={currentPage} onNext={() =>
totalPages={totalPages} setCurrentPage((p) => Math.min(totalPages, p + 1))
onPrev={() => setCurrentPage((p) => Math.max(1, p - 1))} }
onNext={() => />
setCurrentPage((p) => Math.min(totalPages, p + 1)) )}
}
/>
)}
</div> </div>
</div> </div>
</section> </section>
+29 -20
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { Plus } from "lucide-react";
type UiPart = { type UiPart = {
id: string; id: string;
name: string; name: string;
brand: string; brand: string;
caliber?: string;
platform: string; platform: string;
partRole: string; partRole: string;
price: number; price: number;
@@ -36,7 +38,8 @@ export default function PartsGrid(props: {
<div className="mb-3 flex items-center justify-between gap-2"> <div className="mb-3 flex items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-50"> <div className="truncate text-sm font-semibold text-zinc-50">
{part.brand} <span className="font-normal"> {part.name}</span> {part.brand}{" "}
<span className="font-normal"> {part.name}</span>
</div> </div>
</div> </div>
@@ -89,8 +92,9 @@ export default function PartsGrid(props: {
return ( return (
<> <>
{/* Header (DESKTOP ONLY) */} {/* Header (DESKTOP ONLY) */}
<div className="hidden md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_260px] md:items-center md:gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500"> <span className="min-w-0">Part</span> <div className="hidden md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
<span className="min-w-0">Brand</span> <span className="min-w-0">Part</span>
<span className="text-right">Caliber</span>
<span className="text-right">Price</span> <span className="text-right">Price</span>
<span className="text-right">Actions</span> <span className="text-right">Actions</span>
</div> </div>
@@ -102,17 +106,27 @@ export default function PartsGrid(props: {
key={part.id} key={part.id}
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10" className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
> >
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_260px] md:items-center md:gap-4"> <div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4">
{/* Part */} {/* Part */}
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-50"> <Link
href={buildDetailHref(part)}
className="block text-sm font-semibold text-zinc-50 leading-snug line-clamp-2 break-words hover:text-amber-300 transition-colors"
title={part.name}
>
{part.name} {part.name}
</div> </Link>
{/* Dont think we need Brand in the grid */}
{/* <div className="mt-0.5 text-xs text-zinc-500 line-clamp-1">
{part.brand}
{part.caliber ? (
<span className="text-zinc-600"> • {part.caliber}</span>
) : null}
</div> */}
</div> </div>
{/* Caliber (desktop) */}
{/* Brand */} <div className="hidden md:block text-sm text-zinc-300 text-right">
<div className="min-w-0 text-xs text-zinc-400 md:text-sm"> {part.caliber ?? "—"}
{part.brand}
</div> </div>
{/* Price */} {/* Price */}
@@ -122,20 +136,15 @@ export default function PartsGrid(props: {
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link
href={buildDetailHref(part)}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
{onAddToBuild ? ( {onAddToBuild ? (
<button <button
type="button" type="button"
onClick={() => onAddToBuild(part)} onClick={() => onAddToBuild(part)}
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300" className="inline-flex items-center justify-center h-8 w-8 rounded-md bg-amber-400 text-black hover:bg-amber-300 transition-colors"
aria-label="Add to Build"
title="Add to Build"
> >
{addLabel} <Plus className="h-4 w-4" />
</button> </button>
) : part.buyUrl ? ( ) : part.buyUrl ? (
<a <a
@@ -161,4 +170,4 @@ export default function PartsGrid(props: {
</div> </div>
</> </>
); );
} }
+58 -43
View File
@@ -1,60 +1,75 @@
"use client"; "use client";
import { useMemo } from "react"; import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
type Platform = (typeof PLATFORMS)[number];
/**
* PlatformSwitcher
*
* Works on BOTH:
* - /parts/[partRole] (default browse route)
* - /parts/[platform]/[partRole] (canonical route)
*
* Behavior:
* - If you're on /parts/[partRole], switching platform navigates to /parts/[platform]/[partRole]
* (so your URL becomes explicit/shareable).
* - If you're already on /parts/[platform]/[partRole], it swaps the platform segment.
*/
export default function PlatformSwitcher(props: { export default function PlatformSwitcher(props: {
currentPlatform: string; currentPlatform: string;
partRole: string; partRole: string;
// If true, we keep query params when switching (nice for sort/paging) /**
* If true, preserve *all* existing query params (except platform will be overwritten).
* Useful when you want to keep things like ?from=builder or future filters.
*/
preserveQuery?: boolean; preserveQuery?: boolean;
/**
* Force route behavior.
* - "browse": /parts/[role]?platform=...
* - "detail": /parts/p/[platform]/[role]
* Default: auto based on current pathname
*/
mode?: "browse" | "detail";
}) { }) {
const { currentPlatform, partRole, preserveQuery = true } = props;
const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryString = useMemo(() => { const inferredMode: "browse" | "detail" = pathname.startsWith("/parts/p/")
if (!preserveQuery) return ""; ? "detail"
const q = searchParams?.toString(); : "browse";
return q ? `?${q}` : "";
}, [searchParams, preserveQuery]);
function go(platform: string) { const mode = props.mode ?? inferredMode;
// We always navigate to canonical to keep it simple & consistent
router.push(`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}${queryString}`); const buildHref = (nextPlatform: Platform) => {
} if (mode === "detail") {
return `/parts/p/${encodeURIComponent(nextPlatform)}/${encodeURIComponent(
props.partRole
)}`;
}
// browse mode => keep on /parts/[role] and just change the query param
const qp = props.preserveQuery
? new URLSearchParams(searchParams.toString())
: new URLSearchParams();
qp.set("platform", nextPlatform);
return {
pathname: `/parts/${encodeURIComponent(props.partRole)}`,
query: Object.fromEntries(qp.entries()),
} as const;
};
return ( return (
<div className="flex items-center gap-2"> <div className="inline-flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950/60 p-1">
<span className="text-xs uppercase tracking-[0.14em] text-zinc-500">Platform</span> {PLATFORMS.map((p) => {
<select const active = p === props.currentPlatform;
value={currentPlatform} return (
onChange={(e) => go(e.target.value)} <Link
className="rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400" key={p}
> href={buildHref(p)}
<option value="AR-15">AR-15</option> className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
<option value="AR-10">AR-10</option> active
<option value="AR-9">AR-9</option> ? "bg-zinc-800 text-zinc-50"
<option value="AK-47">AK-47</option> : "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/60"
</select> }`}
>
<span className="ml-2 text-[0.7rem] text-zinc-500"> {p}
{pathname} </Link>
</span> );
})}
</div> </div>
); );
} }
+3 -2
View File
@@ -122,11 +122,12 @@ export default function ProductDetailPageClient(props: {
</h1> </h1>
</div> </div>
<PlatformSwitcher currentPlatform={platform} partRole={partRole} /> {/* temp disabling while I rework the routing. */}
{/* <PlatformSwitcher currentPlatform={platform} partRole={partRole} /> */}
</div> </div>
<div className="flex items-center gap-2 text-xs text-zinc-500"> <div className="flex items-center gap-2 text-xs text-zinc-500">
<Link className="hover:text-amber-200" href={`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}> <Link className="hover:text-amber-200" href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}>
Back to list Back to list
</Link> </Link>
<span></span> <span></span>
+80 -33
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;
@@ -30,6 +31,12 @@ type AuthContextValue = {
}) => Promise<void>; }) => Promise<void>;
logout: () => void; logout: () => void;
getAuthHeaders: () => HeadersInit; getAuthHeaders: () => HeadersInit;
/**
* Used for non-password auth flows (ex: magic link).
* Persists session exactly like login/register.
*/
setSession: (token: string, user: NonNullable<AuthUser>) => void;
}; };
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
@@ -42,22 +49,56 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser>(null); const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
// Hydrate from localStorage on first load /**
* Persist auth to localStorage
*/
const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return;
if (nextToken) {
window.localStorage.setItem(TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (nextUser) {
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else {
window.localStorage.removeItem(USER_KEY);
}
},
[]
);
/**
* ✅ Hydrate from localStorage ONCE
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
*/
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY); const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY); const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken) { if (storedToken && storedUser) {
setToken(storedToken);
}
if (storedUser) {
try { try {
setUser(JSON.parse(storedUser)); const parsedUser = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser);
// ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: storedToken,
role: parsedUser?.role,
}),
}).catch(() => {});
} catch { } catch {
// bad JSON? wipe it window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY); window.localStorage.removeItem(USER_KEY);
} }
} }
@@ -65,21 +106,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false); setLoading(false);
}, []); }, []);
const persistAuth = useCallback((nextToken: string | null, nextUser: AuthUser) => { /**
if (typeof window === "undefined") return; * Used by login/register/magic-link
*/
const setSession = useCallback(
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
if (nextToken) { // ✅ Make middleware / server aware of the session
window.localStorage.setItem(TOKEN_KEY, nextToken); fetch("/api/auth/session", {
} else { method: "POST",
window.localStorage.removeItem(TOKEN_KEY); headers: { "Content-Type": "application/json" },
} body: JSON.stringify({
token: nextToken,
if (nextUser) { role: nextUser.role,
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser)); }),
} else { }).catch(() => {});
window.localStorage.removeItem(USER_KEY); },
} [persistAuth]
}, []); );
const login = useCallback( const login = useCallback(
async ({ email, password }: { email: string; password: string }) => { async ({ email, password }: { email: string; password: string }) => {
@@ -98,24 +145,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await res.json(); const data = await res.json();
// Adjust these to match your backend response shape
const nextToken: string = data.token ?? data.accessToken; const nextToken: string = data.token ?? data.accessToken;
const nextUser: AuthUser = const nextUser: AuthUser =
data.user ?? data.user ??
({ ({
uuid: data.uuid,
email: data.email ?? email, email: data.email ?? email,
displayName: data.displayName ?? null, displayName: data.displayName ?? null,
role: data.role ?? "USER", role: data.role ?? "USER",
} as AuthUser); } as AuthUser);
setToken(nextToken); setSession(nextToken, nextUser as NonNullable<AuthUser>);
setUser(nextUser);
persistAuth(nextToken, nextUser);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[persistAuth] [setSession]
); );
const register = useCallback( const register = useCallback(
@@ -147,25 +192,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser = const nextUser: AuthUser =
data.user ?? data.user ??
({ ({
uuid: data.uuid,
email: data.email ?? email, email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null, displayName: data.displayName ?? displayName ?? null,
role: data.role ?? "USER", role: data.role ?? "USER",
} as AuthUser); } as AuthUser);
setToken(nextToken); setSession(nextToken, nextUser as NonNullable<AuthUser>);
setUser(nextUser);
persistAuth(nextToken, nextUser);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[persistAuth] [setSession]
); );
const logout = useCallback(() => { const logout = useCallback(() => {
setToken(null); setToken(null);
setUser(null); setUser(null);
persistAuth(null, null); persistAuth(null, null);
// Clear server session cookies
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
}, [persistAuth]); }, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => { const getAuthHeaders = useCallback((): HeadersInit => {
@@ -180,16 +227,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register, register,
logout, logout,
getAuthHeaders, getAuthHeaders,
setSession,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }
// 🔑 This is what your useApi hook imports
export function useAuth(): AuthContextValue { export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext); const ctx = useContext(AuthContext);
if (!ctx) { if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider"); throw new Error("useAuth must be used within an AuthProvider");
} }
return ctx; return ctx;
} }
+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]);
} }
+34 -9
View File
@@ -12,15 +12,40 @@ const ALWAYS_ALLOW = new Set<string>([
"/sitemap.xml", "/sitemap.xml",
]); ]);
// Match common public/static file extensions so middleware never blocks them const PUBLIC_FILE =
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i; /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
export function middleware(req: NextRequest) { export function middleware(req: NextRequest) {
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
// ✅ Always allow Next internals + static assets (public/ and _next/) console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT);
// =========================
// 1) Admin gate (always on)
// =========================
if (pathname.startsWith("/admin")) {
const token = req.cookies.get("bb_access_token")?.value;
const role = req.cookies.get("bb_role")?.value; // optional
const ok = !!token && (!role || role === "ADMIN");
if (!ok) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
// =====================================
// 2) Launch-only gate (your existing one)
// =====================================
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
// ✅ Always allow Next internals + static assets
if ( if (
pathname.startsWith("/_next") || pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") || pathname.startsWith("/favicon") ||
@@ -31,17 +56,17 @@ export function middleware(req: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// ✅ Allow the root + a few public files
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next(); if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
// 🚫 Everything else: hide it
return NextResponse.rewrite(new URL("/404", req.url)); return NextResponse.rewrite(new URL("/404", req.url));
} }
` 1`
export const config = { export const config = {
// Run middleware on everything *except* Next internals and obvious static files.
// This keeps launch gating from ever breaking your logo/images/fonts/etc.
matcher: [ matcher: [
// run on everything except Next internals + obvious static files
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)", "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)",
], ],
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1133.86 425.2">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-1, .cls-2 {
fill-rule: evenodd;
}
.cls-2 {
fill: #febe2a;
}
</style>
</defs>
<g id="Page_1" data-name="Page 1">
<g id="Icon">
<g>
<path class="cls-2" d="M314.03,138.62c4.07,8.49,3.1,16.88-2.79,24.22l-39.94,49.76,39.94,49.76c5.89,7.34,6.86,15.74,2.79,24.22-4.07,8.49-11.22,12.99-20.64,12.99h-91.92v42.9h91.92c25.92,0,48.11-13.97,59.32-37.34s8.21-49.42-8.02-69.63l-18.38-22.9,18.38-22.9c16.23-20.21,19.23-46.26,8.02-69.63s-33.4-37.34-59.32-37.34h-91.92v42.9h91.92c9.41,0,16.57,4.5,20.64,12.99h0Z"/>
<polygon class="cls-2" points="271.3 212.6 236.9 169.76 181.89 169.76 216.28 212.6 181.89 255.44 236.9 255.44 271.3 212.6"/>
<polygon class="cls-2" points="146.46 125.63 201.48 125.63 167.04 82.73 57 82.73 126.87 169.76 181.89 169.76 146.46 125.63"/>
<path class="cls-2" d="M150.79,199.56c-14.45-3.22-31.32-5.34-47.86-5.34h-33.96v13.15l-59.88,5.22,59.88,5.22v13.15h33.96c16.54,0,33.41-2.12,47.86-5.34,12.64-2.81,23.43-6.46,30.53-10.27l.08-2.77-.08-2.77c-7.11-3.8-17.9-7.46-30.53-10.27h0Z"/>
<polygon class="cls-2" points="181.89 255.44 126.87 255.44 57 342.47 167.04 342.47 201.48 299.56 146.46 299.56 181.89 255.44"/>
</g>
</g>
<g id="BattlBuilders_text" data-name="BattlBuilders text">
<g>
<path class="cls-1" d="M478.75,241.24c-4.01-13.79-16.75-23.88-31.84-23.88h-50.03v125.1h52.33c18.36-.08,33.02-15,33.02-33.16v-5.76c0-11.16-5.51-21.02-13.96-27.04,7.86-6.63,11.8-15.78,11.8-26,0-3.22-.46-6.34-1.32-9.28h0ZM425.57,241.24h15.07c5.93,0,10.73,4.81,10.73,10.73v1.91c0,5.93-4.81,10.73-10.73,10.73h-15.07v-23.37h0ZM425.57,288.49h17.23c5.93,0,10.73,4.81,10.73,10.73v8.64c0,5.95-4.8,10.71-10.73,10.73h-17.23v-30.1h0Z"/>
<polygon class="cls-1" points="588.81 217.37 588.81 342.47 617.5 342.47 617.5 217.37 588.81 217.37"/>
<polygon class="cls-1" points="629.21 217.37 629.21 342.47 703.46 342.47 703.46 318.59 657.89 318.59 657.89 217.37 629.21 217.37"/>
<path class="cls-1" d="M712.99,217.37v125.1h52.05c18.35,0,33.29-14.68,33.29-33.16v-58.79c0-18.21-14.74-33.15-33.15-33.15h-52.19ZM741.68,318.59v-77.35h17.23c5.9,0,10.69,4.76,10.73,10.65v56.04c-.04,5.96-4.87,10.65-10.81,10.65h-17.15Z"/>
<polygon class="cls-1" points="810.04 217.37 810.04 342.47 886.77 342.47 886.77 318.59 838.73 318.59 838.73 291.85 876.86 291.85 876.86 267.98 838.73 267.98 838.73 241.24 886.77 241.24 886.77 217.37 810.04 217.37"/>
<path class="cls-1" d="M993.35,250.52c.29,10.37,1.8,16.6,18.51,28.4l29.13,20.82c4.72,3.37,7.36,5.55,7.36,9.26,0,4.95-5.22,9.6-10.63,9.6h-4.95c-5.9,0-10.69-4.76-10.73-10.65v-4.84h-28.69v6.21c0,3.22.46,6.33,1.32,9.28,4,13.74,16.68,23.88,31.84,23.88h17.48c15.16,0,27.83-10.13,31.84-23.88.86-2.95,1.32-6.06,1.32-9.28-.29-10.37-1.8-16.6-18.51-28.4l-29.13-20.82c-4.72-3.37-7.36-5.55-7.36-9.26,0-4.96,5.22-9.6,10.63-9.6h4.95c5.9,0,10.69,4.76,10.73,10.65v4.84h28.69v-6.21c0-3.22-.46-6.33-1.32-9.28-4-13.74-16.68-23.88-31.84-23.88h-17.48c-15.16,0-27.83,10.13-31.84,23.88-.86,2.95-1.32,6.06-1.32,9.28h0Z"/>
<path class="cls-1" d="M981.64,250.52c0-18.21-14.74-33.15-33.16-33.15h-52.19v125.1h28.69v-43.33h17.16c5.97,0,10.81,4.74,10.81,10.73v22.34c0,4.75.91,8.65,2.42,10.27h28.69c-1.52-1.61-2.43-5.52-2.43-10.27v-18.74c0-10.31-4.76-19.97-12.92-26.27,8.17-6.3,12.92-15.96,12.92-26.27v-10.41h0ZM942.22,241.24c5.93,0,10.73,4.81,10.73,10.73v12.55c0,5.99-4.84,10.73-10.81,10.73h-17.16v-34.02h17.24Z"/>
<path class="cls-1" d="M548.42,217.37v93.21h-2.51v3.84h2.51v4.17h-27.97v-4.17h2.51v-3.84h-2.51v-93.21h-28.69v91.95c0,18.16,14.66,33.08,33.02,33.16h19.31c15.1-.06,27.71-10.18,31.7-23.88.86-2.95,1.32-6.06,1.32-9.28v-91.95h-28.69Z"/>
<path class="cls-1" d="M478.75,106.61c-4.01-13.79-16.75-23.88-31.84-23.88h-50.03v125.1h52.33c18.36-.07,33.02-15,33.02-33.15v-5.76c0-11.16-5.51-21.02-13.96-27.04,7.86-6.63,11.8-15.78,11.8-26,0-3.22-.46-6.34-1.32-9.28h0ZM425.57,106.61h15.07c5.93,0,10.73,4.81,10.73,10.73v1.91c0,5.93-4.81,10.73-10.73,10.73h-15.07v-23.37h0ZM425.57,153.85h17.23c5.93,0,10.73,4.81,10.73,10.73v8.64c0,5.95-4.8,10.71-10.73,10.73h-17.23v-30.1h0Z"/>
<path class="cls-1" d="M535.69,97.25c5.99,13.8,8.73,28.29,8.73,41.27v25.48h-19.97v-25.48c0-12.98,2.74-27.48,8.73-41.27h2.51ZM577.1,115.89c0-18.16-14.66-33.08-33.02-33.15h-19.31c-18.36.07-33.02,15-33.02,33.15v91.95h28.69v-10.03l2.56-11.48v-13.17h22.85v13.17l2.56,11.48v10.03h28.69v-91.95h0Z"/>
<polygon class="cls-1" points="766.9 82.73 766.9 207.83 841.16 207.83 841.16 183.96 795.59 183.96 795.59 82.73 766.9 82.73"/>
<polygon class="cls-1" points="757.37 82.73 669.97 82.73 669.97 106.6 699.33 106.6 699.33 207.83 728.01 207.83 728.01 106.6 757.37 106.6 757.37 82.73"/>
<polygon class="cls-1" points="660.44 106.6 660.44 82.73 571.56 82.73 585.34 106.6 602.39 106.6 602.39 207.83 631.08 207.83 631.08 106.6 660.44 106.6"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 B

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350.31 259.74">
<defs>
<style>
.cls-1 {
fill: #febe2a;
fill-rule: evenodd;
}
</style>
</defs>
<g id="Page_1" data-name="Page 1">
<g id="Icon">
<g>
<path class="cls-1" d="M304.94,55.89c4.07,8.49,3.1,16.88-2.79,24.22l-39.94,49.75,39.94,49.75c5.89,7.34,6.86,15.74,2.79,24.22-4.07,8.49-11.22,12.99-20.63,12.99h-91.92v42.9h91.92c25.92,0,48.11-13.97,59.32-37.34,11.21-23.37,8.21-49.42-8.02-69.63l-18.38-22.9,18.38-22.9c16.23-20.21,19.23-46.26,8.02-69.63C332.41,13.97,310.22,0,284.3,0h-91.92v42.9h91.92c9.41,0,16.57,4.5,20.63,12.99h0Z"/>
<polygon class="cls-1" points="262.2 129.87 227.81 87.03 172.79 87.03 207.19 129.87 172.79 172.71 227.81 172.71 262.2 129.87 262.2 129.87"/>
<polygon class="cls-1" points="137.37 42.9 192.39 42.9 157.94 0 47.91 0 117.77 87.03 172.79 87.03 137.37 42.9 137.37 42.9"/>
<path class="cls-1" d="M141.7,116.83c-14.45-3.22-31.32-5.34-47.86-5.34h-33.96v13.15L0,129.87l59.88,5.22v13.15h33.96c16.54,0,33.41-2.12,47.86-5.34,12.64-2.81,23.43-6.46,30.53-10.27l.08-2.77-.08-2.77c-7.11-3.8-17.9-7.46-30.53-10.27h0Z"/>
<polygon class="cls-1" points="172.79 172.71 117.77 172.71 47.91 259.74 157.94 259.74 192.39 216.83 137.37 216.83 172.79 172.71 172.79 172.71"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB