24 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
dstrawsb b224e2d0e0 new email management page 2025-12-19 23:16:50 -05:00
sean f81974cc0b pass builds from vault to builder 2025-12-19 08:36:48 -05:00
sean b76ced45f1 wired up an accounts page and my builds (my vault) 2025-12-19 08:05:03 -05:00
sean c6d1bce771 setting up dark/white mode. fixed some other small things 2025-12-18 21:06:08 -05:00
sean 7f3818f795 ui using new v1 api for products 2025-12-18 19:18:15 -05:00
61 changed files with 5275 additions and 1936 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 />
</>
);
}
+439
View File
@@ -0,0 +1,439 @@
// app/(account)/account/page.tsx
"use client";
import { useMemo, useState } from "react";
import { useSearchParams, useRouter } 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 AccountPage() {
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 (!user) {
return (
<div className="text-sm opacity-70">
Youre not logged in. Please log in to view your account.
</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 (
<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>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm opacity-70">Basic account info.</p>
</div>
{/* Profile card */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="text-sm">
<span className="opacity-70">Email:</span>{" "}
<span className="font-medium">{user.email}</span>
</div>
{/* Display name (inline editable) */}
<div className="text-sm">
<span className="opacity-70">Display name:</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 className="text-sm">
<span className="opacity-70">Role:</span>{" "}
<span className="font-medium">{user.role}</span>
</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>
);
}
+61
View File
@@ -0,0 +1,61 @@
// app/(account)/layout.tsx
import Link from "next/link";
import AccountChrome from "./AccountChrome";
const nav = [
{ href: "/account", label: "My Account" },
{ href: "/account/settings", label: "Settings" },
{ href: "/vault", label: "My Vault" },
];
export default function AccountLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<AccountChrome />
<div className="mx-auto w-full max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
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.
</p>
</div>
<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
</Link>
</div>
<div className="grid gap-6 lg:grid-cols-12">
<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.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
>
{item.label}
</Link>
))}
</nav>
</aside>
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
{children}
</main>
</div>
</div>
</div>
);
}
@@ -1,13 +1,36 @@
// 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 { Pencil, 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";
@@ -30,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";
@@ -113,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] =>
@@ -128,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);
@@ -142,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>("");
@@ -152,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) {
@@ -164,7 +230,9 @@ export default function GunbuilderPage() {
const selectedParts: Part[] = useMemo(() => { const selectedParts: Part[] = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
const resolved = Object.values(build) const resolved = Object.values(build)
.map((partId) => (partId ? parts.find((p) => p.id === partId) : undefined)) .map((partId) =>
partId ? parts.find((p) => p.id === partId) : undefined
)
.filter(Boolean) as Part[]; .filter(Boolean) as Part[];
return resolved.filter((p) => { return resolved.filter((p) => {
@@ -174,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";
@@ -268,28 +354,11 @@ 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 }) => {
// NOTE:
// - We DO NOT auto-remove any existing selections.
// - We show subtle overlap guidance (chips + an optional “heads up” toast).
const warn = (msg: string) => { const warn = (msg: string) => {
setShareStatus(msg); setShareStatus(msg);
window.setTimeout(() => setShareStatus(null), 4000); window.setTimeout(() => setShareStatus(null), 4000);
@@ -298,7 +367,6 @@ export default function GunbuilderPage() {
const hints = getSelectionHints(categoryId, build); const hints = getSelectionHints(categoryId, build);
if (hints.length > 0) warn(hints[0]); if (hints.length > 0) warn(hints[0]);
// ✅ Only set the selection. No deletes. No clearing.
setBuild((prev) => ({ setBuild((prev) => ({
...prev, ...prev,
[categoryId]: partId, [categoryId]: partId,
@@ -307,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();
@@ -316,11 +386,10 @@ export default function GunbuilderPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const scopedUrl = `${API_BASE_URL}/api/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/products`;
const [scopedRes, universalRes] = await Promise.all([ const [scopedRes, universalRes] = await Promise.all([
fetch(scopedUrl, { signal: controller.signal }), fetch(scopedUrl, { signal: controller.signal }),
@@ -352,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)
@@ -368,7 +438,7 @@ export default function GunbuilderPage() {
brand: p.brand, brand: p.brand,
price: p.price ?? 0, price: p.price ?? 0,
imageUrl: imageUrl:
((p as any).imageUrl ?? (p as any).mainImageUrl) ?? undefined, (p as any).imageUrl ?? (p as any).mainImageUrl ?? undefined,
affiliateUrl: buyUrl, affiliateUrl: buyUrl,
url: buyUrl, url: buyUrl,
notes: undefined, notes: undefined,
@@ -379,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]) {
@@ -401,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 {
@@ -411,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;
@@ -435,9 +510,162 @@ export default function GunbuilderPage() {
}); });
}, [platform]); }, [platform]);
// -----------------------------
// Load a saved build from Vault via ?load=<uuid> OR legacy ?build=<uuid>
// -----------------------------
useEffect(() => {
const loadUuid = searchParams.get("load");
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 () => {
try {
setShareStatus("Loading build…");
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
// explicitly attach JWT headers.
const res = await fetch(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
{
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
}
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`Load failed (${res.status}) ${txt}`);
}
const dto = await res.json(); // BuildDto w/ items
const nextBuild: Record<string, string> = {};
for (const it of dto.items ?? []) {
if (!it?.slot || !it?.productId) continue;
nextBuild[it.slot] = String(it.productId);
}
setBuild(nextBuild);
setShareStatus(`Loaded: ${dto.title}`);
// Clean URL so refresh doesn't re-load repeatedly
const qp = new URLSearchParams(searchParams.toString());
qp.delete("load");
if (buildParam && isUuid(buildParam)) qp.delete("build");
const next = qp.toString();
router.replace(next ? `/builder?${next}` : "/builder", {
scroll: false,
});
} catch (e: any) {
setShareStatus(e?.message ?? "Failed to load build");
} finally {
window.setTimeout(() => setShareStatus(null), 4500);
}
};
run();
}, [
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");
@@ -476,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 });
} }
@@ -495,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;
@@ -508,7 +735,9 @@ export default function GunbuilderPage() {
const payload = JSON.stringify(build); const payload = JSON.stringify(build);
const encoded = window.btoa(payload); const encoded = window.btoa(payload);
const origin = window.location?.origin ?? ""; const origin = window.location?.origin ?? "";
const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`; const url = `${origin}/builder/build?build=${encodeURIComponent(
encoded
)}`;
setShareUrl(url); setShareUrl(url);
} catch { } catch {
setShareUrl(""); setShareUrl("");
@@ -523,6 +752,7 @@ export default function GunbuilderPage() {
[] []
); );
// Copy summary text to clipboard
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.");
@@ -530,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("");
@@ -590,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.");
@@ -606,7 +836,100 @@ export default function GunbuilderPage() {
}; };
return ( return (
<main className="min-h-screen bg-black 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">
@@ -615,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
@@ -633,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");
@@ -665,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">
@@ -676,20 +1001,6 @@ export default function GunbuilderPage() {
</div> </div>
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap"> <div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
<Link
href={{
pathname: "/builder/build",
query: platform ? { platform } : {},
}}
className={`w-full sm:w-auto rounded-md border border-amber-400/60 bg-amber-400/10 px-3 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 text-center transition-colors ${
selectedParts.length === 0
? "opacity-40 cursor-not-allowed pointer-events-none"
: ""
}`}
>
Build Summary
</Link>
<button <button
type="button" type="button"
onClick={handleShare} onClick={handleShare}
@@ -706,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 ${
@@ -720,6 +1033,28 @@ export default function GunbuilderPage() {
> >
Clear Build Clear Build
</button> </button>
{/* Save As… triggers modal */}
<button
type="button"
onClick={() => setSaveAsOpen(true)}
disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
selectedParts.length === 0
? "bg-zinc-900/80 text-zinc-600 border-zinc-800 cursor-not-allowed"
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
}`}
>
Save As
</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>
@@ -734,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">
@@ -746,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"
@@ -754,6 +1091,7 @@ export default function GunbuilderPage() {
> >
Share Share
</button> </button>
<button <button
type="button" type="button"
onClick={handleCopyLink} onClick={handleCopyLink}
@@ -807,9 +1145,9 @@ export default function GunbuilderPage() {
{/* Layout */} {/* Layout */}
<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-3 md:p-4">
<p className="mb-4 text-xs text-zinc-500"> <p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your current Work top-down through the major sections. Each row shows your
pick for that part type, with price and a direct buy linkor a quick current pick for that part type, with price and a direct buy linkor
way to choose a part if you haven&apos;t picked one yet. a quick way to choose a part if you haven&apos;t picked one yet.
</p> </p>
{loading && ( {loading && (
@@ -899,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,
@@ -945,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>
@@ -955,19 +1310,21 @@ export default function GunbuilderPage() {
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{selectedPart && selectedPart.url ? ( {selectedPart && selectedPart.url ? (
<> <>
<Link <button
href={{ type="button"
pathname: `/parts/p/${platform}/${category.id}`, onClick={() => {
query: platform setBuild((prev) => {
? { platform } const next = { ...prev };
: {}, delete next[category.id];
return next;
});
}} }}
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors" className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-red-500/15 hover:border-red-400 transition-colors"
aria-label="Change part" aria-label="Remove part"
title="Change part" title="Remove part"
> >
<Pencil className="h-3.5 w-3.5 text-zinc-300" /> <X className="h-3.5 w-3.5 text-zinc-400 hover:text-red-400" />
</Link> </button>
<a <a
href={selectedPart.url} href={selectedPart.url}
@@ -982,12 +1339,9 @@ export default function GunbuilderPage() {
</> </>
) : hasParts ? ( ) : hasParts ? (
<Link <Link
href={{ href={`/parts/p/${encodeURIComponent(
pathname: `/parts/p/${platform}/${category.id}`, platform
query: platform )}/${encodeURIComponent(category.id)}`}
? { platform }
: {},
}}
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
@@ -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
/>
);
}
@@ -122,7 +122,7 @@ export default function ProductDetailsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/products/${numericId}`; const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, { signal: controller.signal });
if (!res.ok) { if (!res.ok) {
@@ -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
@@ -56,13 +56,13 @@ export function getPricingHistory(
/** /**
* Get retailer offers for a part * Get retailer offers for a part
* Real implementation using Ballistic backend: * Real implementation using Ballistic backend:
* GET /api/products/{productId}/offers * GET /api/v1/products/{productId}/offers
*/ */
export async function getRetailerOffers( export async function getRetailerOffers(
productId: string, productId: string,
): Promise<RetailerOffer[]> { ): Promise<RetailerOffer[]> {
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/products/${productId}/offers`, `${API_BASE_URL}/api/v1/products/${productId}/offers`,
{ {
// This will be called from the client detail page, // This will be called from the client detail page,
// so we *don't* use cache here. // so we *don't* use cache here.
@@ -135,7 +135,7 @@ export default function CategoryPage() {
search.append("partRoles", r); search.append("partRoles", r);
} }
const url = `${API_BASE_URL}/api/products?${search.toString()}`; const url = `${API_BASE_URL}/api/v1/products?${search.toString()}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, { signal: controller.signal });
@@ -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>
@@ -7,9 +7,16 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
/**
* API Shapes
* - OfferFromApi: what /api/v1/products/:id/offers returns
* - 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;
@@ -23,21 +30,19 @@ type GunbuilderProductFromApi = {
partRole: string; partRole: string;
price: number | null; price: number | null;
// image fields can vary depending on endpoint/version // Optional (legacy fallback label if product.offers is missing)
merchantName?: string | null;
imageUrl?: string | null; imageUrl?: string | null;
mainImageUrl?: string | null; mainImageUrl?: string | null;
// single best-link (legacy)
buyUrl?: string | null; buyUrl?: string | null;
// stock info
inStock?: boolean | null; inStock?: boolean | null;
// richer details (optional)
shortDescription?: string | null; shortDescription?: string | null;
description?: string | null; description?: string | null;
// offers (optional) // (May be empty depending on endpoint; we now fetch offers separately)
offers?: OfferFromApi[] | null; offers?: OfferFromApi[] | null;
}; };
@@ -50,6 +55,10 @@ const STORAGE_KEY = "gunbuilder-build-state";
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
/**
* Extract numeric id from slug like: "1217-radian-ar-15..."
* This keeps URLs pretty while still letting us fetch by ID.
*/
function extractNumericId(productSlug: string): number | null { function extractNumericId(productSlug: string): number | null {
if (!productSlug) return null; if (!productSlug) return null;
const first = productSlug.split("-")[0]; const first = productSlug.split("-")[0];
@@ -62,19 +71,57 @@ 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:
* 1) lowest price first
* 2) in-stock before out-of-stock
* 3) merchant label tiebreaker
*/
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] { function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
return [...offers].sort((a, b) => { return [...offers].sort((a, b) => {
const ap = a.price ?? Number.POSITIVE_INFINITY; const ap = a.price ?? Number.POSITIVE_INFINITY;
const bp = b.price ?? Number.POSITIVE_INFINITY; const bp = b.price ?? Number.POSITIVE_INFINITY;
if (ap !== bp) return ap - bp; if (ap !== bp) return ap - bp;
// in-stock first // in-stock first (false sorts later)
const aStock = a.inStock === false ? 1 : 0; const aStock = a.inStock === false ? 1 : 0;
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;
// merchant name return merchantLabel(a).localeCompare(merchantLabel(b));
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
}); });
} }
@@ -97,6 +144,10 @@ export default function ProductDetailsPage() {
[partRoleParam] [partRoleParam]
); );
/**
* categoryId drives builder state selection/removal
* (based on partRole -> category mapping).
*/
const categoryId = useMemo(() => { const categoryId = useMemo(() => {
return ( return (
PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[partRoleParam] ??
@@ -136,13 +187,25 @@ export default function ProductDetailsPage() {
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined; const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
const isSelected = const isSelected =
!!categoryId && selectedPartIdForCategory === (numericId ? String(numericId) : ""); !!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : "");
// Product fetch state
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null); const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
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);
// If platform segment is missing/empty, normalize into the new route // 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:
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
*/
useEffect(() => { useEffect(() => {
if (platformParam) return; if (platformParam) return;
@@ -157,7 +220,10 @@ export default function ProductDetailsPage() {
// 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 /**
* Fetch product details from API:
* GET /api/v1/products/:id
*/
useEffect(() => { useEffect(() => {
if (!numericId) { if (!numericId) {
setError("Invalid product id."); setError("Invalid product id.");
@@ -172,7 +238,7 @@ export default function ProductDetailsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/products/${numericId}`; const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, { signal: controller.signal });
if (!res.ok) { if (!res.ok) {
@@ -193,10 +259,49 @@ 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:
* - If selected -> remove from build
* - Else -> add to build
*/
const handleTogglePart = () => { const handleTogglePart = () => {
if (!numericId) return; if (!numericId) return;
// If mapping is missing, dont send a bogus builder action
if (!categoryId) { if (!categoryId) {
alert( alert(
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.` `No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
@@ -219,19 +324,18 @@ export default function ProductDetailsPage() {
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null; const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
// Offers normalization: /**
// - If product.offers exists, use it. * Offers normalization:
// - Otherwise, fall back to a single “offer” from product.buyUrl/product.price. * - Prefer offers endpoint data
* - 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: "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,
@@ -240,7 +344,30 @@ export default function ProductDetailsPage() {
} }
return []; return [];
}, [product]); }, [offersFromApi, product]);
/**
* Best offer (sorted offers[0])
*/
const bestOffer = offers[0] ?? null;
/**
* Helper: identify the best offer row
*/
const isBestOffer = (offer: OfferFromApi) =>
!!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 (
@@ -256,9 +383,9 @@ export default function ProductDetailsPage() {
<span className="text-zinc-200">catalogMappings</span>. <span className="text-zinc-200">catalogMappings</span>.
</p> </p>
<Link <Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent( href={`/parts/p/${encodeURIComponent(
partRoleParam platform
)}`} )}/${encodeURIComponent(partRoleParam)}`}
className="text-amber-300 hover:text-amber-200 underline" className="text-amber-300 hover:text-amber-200 underline"
> >
Back to Parts List Back to Parts List
@@ -276,16 +403,19 @@ export default function ProductDetailsPage() {
<header className="mb-6"> <header className="mb-6">
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500"> <div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<Link <Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }} href={{
pathname: "/builder",
query: platform ? { platform } : {},
}}
className="hover:text-zinc-300" className="hover:text-zinc-300"
> >
The Armory Builder
</Link> </Link>
<span className="text-zinc-700">/</span> <span className="text-zinc-700">/</span>
<Link <Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent( href={`/parts/p/${encodeURIComponent(
partRoleParam platform
)}`} )}/${encodeURIComponent(partRoleParam)}`}
className="hover:text-zinc-300" className="hover:text-zinc-300"
> >
Parts Parts
@@ -303,36 +433,10 @@ export default function ProductDetailsPage() {
Product <span className="text-amber-300">Details</span> Product <span className="text-amber-300">Details</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl"> <p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Offers, pricing placeholders, and builder actions all on the canonical Offers, pricing placeholders, and builder actions all on the
/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>
@@ -352,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
@@ -377,20 +488,20 @@ 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>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Link <Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent( href={`/parts/p/${encodeURIComponent(
partRoleParam platform
)}`} )}/${encodeURIComponent(partRoleParam)}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800" className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
> >
Back to List Back to List
@@ -405,12 +516,12 @@ 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">
@@ -422,12 +533,13 @@ export default function ProductDetailsPage() {
Pricing graph will go here Pricing graph will go here
</div> </div>
<p className="mt-2 text-[11px] text-zinc-500"> <p className="mt-2 text-[11px] text-zinc-500">
Well wire this to price snapshots once the offer model is stable. Well wire this to price snapshots once the offer model is
stable.
</p> </p>
</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">
@@ -467,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>
@@ -495,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>
@@ -512,15 +628,32 @@ export default function ProductDetailsPage() {
</thead> </thead>
<tbody className="divide-y divide-zinc-800"> <tbody className="divide-y divide-zinc-800">
{offers.map((o, idx) => ( {offers.map((o, idx) => (
<tr key={idx} className="hover:bg-zinc-900/40"> <tr
<td className="px-3 py-2 text-zinc-200"> key={String(o.id ?? idx)}
{o.merchantName ?? "Merchant"} className={`hover:bg-zinc-900/40 ${
isBestOffer(o)
? "bg-amber-400/10 border-l-2 border-amber-400"
: ""
}`}
>
<td className="px-3 py-2 text-zinc-200 flex items-center">
<span>{merchantLabel(o)}</span>
{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">
BEST DEAL
</span>
)}
</td> </td>
<td className="px-3 py-2 text-xs"> <td className="px-3 py-2 text-xs">
{o.inStock === false ? ( {o.inStock === false ? (
<span className="text-red-300">Out of stock</span> <span className="text-red-300">
Out of stock
</span>
) : ( ) : (
<span className="text-emerald-300">In stock</span> <span className="text-emerald-300">
In stock
</span>
)} )}
</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">
@@ -547,21 +680,21 @@ export default function ProductDetailsPage() {
</div> </div>
) : ( ) : (
<p className="mt-3 text-sm text-zinc-500"> <p className="mt-3 text-sm text-zinc-500">
No offers attached yet. Once offers are available, this becomes the No offers attached yet. Once offers are available, this
where to buy table. becomes the where to buy table.
</p> </p>
)} )}
{/* Keep a single "best" buy CTA if buyUrl exists */} {/* Best Offer CTA */}
{product.buyUrl ? ( {bestOffer?.buyUrl ? (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<a <a
href={product.buyUrl} href={bestOffer.buyUrl}
target="_blank" target="_blank"
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 Merchant Buy from {merchantLabel(bestOffer)}
</a> </a>
</div> </div>
) : null} ) : null}
@@ -569,20 +702,48 @@ 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>
); );
@@ -1,18 +1,15 @@
// app/(builder)/layout.tsx // app/(builder)/layout.tsx
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { BuilderNav } from "@/components/BuilderNav"; import { BuilderNav } from "@/components/BuilderNav";
import { TopNav } from "@/components/TopNav"; import { TopNav } from "@/components/TopNav";
export default function BuilderLayout({ children }: { children: ReactNode }) { export default function BuilderLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-neutral-950 text-zinc-50"> <div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
<AuthProvider> <TopNav />
<TopNav /> <BuilderNav />
<BuilderNav /> <main className="min-h-screen">{children}</main>
<main className="min-h-screen">{children}</main>
</AuthProvider>
</div> </div>
); );
} }
+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>
);
}
-482
View File
@@ -1,482 +0,0 @@
//
// This page is most likely temporary. We probably do not need this page
//
//
//
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import {
Pencil,
Link2,
Square,
CheckSquare,
} from "lucide-react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const STORAGE_KEY = "gunbuilder-build-state";
type BuildState = Partial<Record<CategoryId, string>>;
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
};
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const UNIVERSAL_CATEGORIES = new Set<CategoryId>([
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
"optic",
"sights",
]);
const CATEGORY_GROUPS: {
id: string;
label: string;
description?: string;
categoryIds: CategoryId[];
}[] = [
{
id: "lower-group",
label: "Lower Receiver Parts",
description:
"Everything from the serialized lower to small parts, fire control, and core controls.",
categoryIds: [
"lower-receiver",
"complete-lower",
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
],
},
{
id: "upper-group",
label: "Upper Receiver Parts",
description:
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
categoryIds: [
"upper-receiver",
"complete-upper",
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
"sights",
"optic",
],
},
{
id: "accessories-group",
label: "Accessories & Gear",
description:
"Universal add-ons that work across platforms — mags, lights, slings, tools, and more.",
categoryIds: [
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
],
},
];
function isValidPlatform(
value: string | null
): value is (typeof PLATFORMS)[number] {
return !!value && (PLATFORMS as readonly string[]).includes(value);
}
function safeDecodeBuildParam(buildParam: string | null): BuildState | null {
if (!buildParam) return null;
try {
// build param is base64(json)
const json = window.atob(buildParam);
const parsed = JSON.parse(json) as BuildState;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function formatPrice(n: number) {
return `$${n.toFixed(2)}`;
}
export default function BuildSummaryPage() {
const searchParams = useSearchParams();
const router = useRouter();
const qpPlatform = searchParams.get("platform");
const platform = isValidPlatform(qpPlatform) ? qpPlatform : "AR-15";
const [build, setBuild] = useState<BuildState>({});
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load build from ?build=... OR localStorage
useEffect(() => {
if (typeof window === "undefined") return;
const buildParam = searchParams.get("build");
const decoded = safeDecodeBuildParam(buildParam);
if (decoded) {
setBuild(decoded);
return;
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
} catch {
setBuild({});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fetch product data so we can resolve ids -> Part info
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoading(true);
setError(null);
// scoped (platform)
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
platform
)}`;
// universal (optional)
const universalUrl = `${API_BASE_URL}/api/products`;
const [scopedRes, universalRes] = await Promise.all([
fetch(scopedUrl, { signal: controller.signal }),
fetch(universalUrl, { signal: controller.signal }).catch(
() => null as any
),
]);
if (!scopedRes || !scopedRes.ok) {
throw new Error(`Failed to load products (${scopedRes?.status ?? ""})`);
}
const scopedData: GunbuilderProductFromApi[] = await scopedRes.json();
let universalData: GunbuilderProductFromApi[] = [];
if (universalRes && universalRes.ok) {
universalData = await universalRes.json();
}
// NOTE: We only need enough to render the summary.
// We will map “everything we can” and ignore unknown categories.
const toCategoryId = (rawRole: string): CategoryId | null => {
const role = (rawRole ?? "").trim().toLowerCase().replace(/_/g, "-");
// quick defensive mapping (mirrors your builder page strategy)
if (role.includes("complete-upper")) return "complete-upper";
if (role.includes("complete-lower")) return "complete-lower";
if (role === "upper-receiver" || role === "upper") return "upper-receiver";
if (role === "lower-receiver" || role === "lower") return "lower-receiver";
if (role.includes("charging")) return "charging-handle";
if (role.includes("handguard")) return "handguard";
if (role.includes("bcg") || role.includes("bolt-carrier")) return "bcg";
if (role.includes("barrel")) return "barrel";
if (role.includes("gas-block") || role.includes("gasblock")) return "gas-block";
if (role.includes("gas-tube") || role.includes("gastube")) return "gas-tube";
if (role.includes("muzzle") || role.includes("flash") || role.includes("brake") || role.includes("comp"))
return "muzzle-device";
if (role.includes("suppress")) return "suppressor";
if (role.includes("lower-parts") || role.includes("lpk")) return "lower-parts";
if (role.includes("trigger")) return "trigger";
if (role.includes("grip")) return "grip";
if (role.includes("safety")) return "safety";
if (role.includes("buffer")) return "buffer";
if (role.includes("stock")) return "stock";
if (role.includes("optic") || role.includes("scope")) return "optic";
if (role.includes("sight")) return "sights";
if (role.includes("mag")) return "magazine";
if (role.includes("weapon-light") || (role.includes("light") && !role.includes("flight")))
return "weapon-light";
if (role.includes("foregrip") || role.includes("grip-vertical")) return "foregrip";
if (role.includes("bipod")) return "bipod";
if (role.includes("sling")) return "sling";
if (role.includes("rail-accessory") || role.includes("rail-attachment")) return "rail-accessory";
if (role.includes("tool")) return "tools";
return null;
};
const normalize = (data: GunbuilderProductFromApi[], isUniversal: boolean): Part[] =>
data
.map((p): Part | null => {
const categoryId = toCategoryId(p.partRole);
if (!categoryId) return null;
if (isUniversal && !UNIVERSAL_CATEGORIES.has(categoryId)) return null;
const buyUrl = p.buyUrl ?? undefined;
return {
id: String(p.id),
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: (p.imageUrl as any) ?? (p as any).mainImageUrl ?? undefined,
affiliateUrl: buyUrl,
url: buyUrl,
notes: undefined,
};
})
.filter((p): p is Part => p !== null);
const scopedParts = normalize(scopedData, false);
const universalParts = normalize(universalData, true);
const seen = new Set<string>();
const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) {
const key = `${p.categoryId}:${p.id}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(p);
}
setParts(merged);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchProducts();
return () => controller.abort();
}, [platform]);
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((g) => g.categoryIds).filter((id) =>
CATEGORIES.some((c) => c.id === id)
),
[]
);
const selectedParts = useMemo(() => {
const out: { categoryId: CategoryId; part?: Part }[] = [];
for (const cid of summaryCategoryOrder) {
const partId = build[cid];
const part = partId ? parts.find((p) => p.id === partId && p.categoryId === cid) : undefined;
out.push({ categoryId: cid, part });
}
return out;
}, [build, parts, summaryCategoryOrder]);
const total = useMemo(
() => selectedParts.reduce((sum, row) => sum + (row.part?.price ?? 0), 0),
[selectedParts]
);
const filledCount = useMemo(
() => selectedParts.filter((r) => !!r.part).length,
[selectedParts]
);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Build <span className="text-amber-300">Summary</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Snapshot of your current selections. (V1: data resolves from your local build state + live products.)
</p>
<div className="mt-3 text-xs text-zinc-500">
Platform: <span className="text-zinc-200 font-semibold">{platform}</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-right">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Total
</div>
<div className="text-2xl font-semibold text-amber-300">
{formatPrice(total)}
</div>
<div className="text-xs text-zinc-500 mt-1">
{filledCount} / {summaryCategoryOrder.length} selected
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const qp = new URLSearchParams();
qp.set("platform", platform);
router.push(`/builder?${qp.toString()}`);
}}
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 hover:border-zinc-600 transition-colors"
>
Back to Builder
</button>
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
>
Edit Build
</Link>
</div>
</div>
</div>
</header>
{/* Body */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
{loading ? (
<p className="py-10 text-center text-sm text-zinc-500">Loading</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : (
<div className="space-y-4">
{CATEGORY_GROUPS.map((group) => {
const rows = group.categoryIds
.filter((cid) => summaryCategoryOrder.includes(cid))
.map((cid) => {
const cat = CATEGORIES.find((c) => c.id === cid);
const partId = build[cid];
const part = partId
? parts.find((p) => p.id === partId && p.categoryId === cid)
: undefined;
return { cid, cat, part };
});
return (
<div key={group.id} className="rounded-md border border-zinc-800 bg-zinc-950/80">
<div className="border-b border-zinc-800 px-4 py-3">
<div className="text-sm font-semibold text-zinc-100">{group.label}</div>
{group.description && (
<div className="mt-1 text-xs text-zinc-500">{group.description}</div>
)}
</div>
<div className="divide-y divide-zinc-900">
{rows.map(({ cid, cat, part }) => (
<div key={cid} className="flex items-start justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-zinc-300">
{cat?.name ?? cid}
</div>
{part ? (
<div className="mt-1 text-sm text-zinc-100 truncate">
<span className="text-zinc-400">{part.brand}</span>{" "}
{part.name}
</div>
) : (
<div className="mt-1 text-sm text-zinc-600">
Not selected
</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="text-xs text-zinc-500">Price</div>
<div className={`text-sm font-semibold ${part ? "text-amber-300" : "text-zinc-600"}`}>
{part ? formatPrice(part.price) : "—"}
</div>
<div className="mt-2 flex justify-end gap-2">
<Link
href={{
pathname: `/parts/p/${platform}/${cid}`,
query: platform ? { platform } : {},
}}
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1.5 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
title={part ? "Change part" : "Choose part"}
aria-label={part ? "Change part" : "Choose part"}
>
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
</Link>
{part?.url ? (
<a
href={part.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2.5 py-1.5 hover:bg-amber-300 transition-colors"
title="Buy part"
aria-label="Buy part"
>
<Link2 className="h-3.5 w-3.5 text-black" />
</a>
) : (
<span className="inline-flex items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 text-[11px] text-zinc-600">
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</section>
<div className="mt-4 text-center text-xs text-zinc-600">
Tip: If you landed here from a shared link, it uses <code>?build=</code> first; otherwise it reads your local build.
</div>
</div>
</main>
);
}
-460
View File
@@ -1,460 +0,0 @@
"use client";
import { useMemo, useStat, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { buildDetailHref } from "@/app/parts/_components/buildDetailHref";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type GunbuilderProductFromApi = {
id: string; // backend UUID string
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
buyUrl: string | null;
};
// Map backend partRole -> CategoryId (align with builder categories)
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
"upper-receiver": "upper",
barrel: "barrel",
handguard: "handguard",
"charging-handle": "chargingHandle",
"buffer-kit": "buffer",
"lower-parts-kit": "lowerParts",
sight: "sights",
};
// Inverse: CategoryId -> backend partRole (for building detail URLs)
const CATEGORY_TO_PART_ROLE: Partial<Record<CategoryId, string>> =
Object.fromEntries(
Object.entries(PART_ROLE_TO_CATEGORY).map(([role, cat]) => [cat, role]),
) as Partial<Record<CategoryId, string>>;
// Encode build state to URL-friendly string
function encodeBuildState(build: BuildState): string {
const entries = Object.entries(build)
.filter(([_, partId]) => partId)
.map(([categoryId, partId]) => `${categoryId}:${partId}`)
.join(",");
return btoa(entries);
}
// Decode URL string to build state
function decodeBuildState(encoded: string): BuildState {
try {
const decoded = atob(encoded);
const build: BuildState = {};
decoded.split(",").forEach((entry) => {
const [categoryId, partId] = entry.split(":");
if (categoryId && partId) {
build[categoryId as CategoryId] = partId;
}
});
return build;
} catch {
return {};
}
}
export default function BuildDetailsPage() {
const searchParams = useSearchParams();
const router = useRouter();
// ✅ Option A: platform is a query param and is passed through to backend
const platform = searchParams.get("platform") ?? "AR-15";
const [build, setBuild] = useState<BuildState>({});
const [shareUrl, setShareUrl] = useState<string>("");
const [copied, setCopied] = useState(false);
const [parts, setParts] = useState<Part[]>([]);
const [loadingParts, setLoadingParts] = useState(true);
const [partsError, setPartsError] = useState<string | null>(null);
// Load build from URL params or localStorage
useEffect(() => {
const buildParam = searchParams.get("build");
if (buildParam) {
const decoded = decodeBuildState(buildParam);
setBuild(decoded);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
} catch {
// ignore storage failures (private mode, etc.)
}
} else if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
setBuild(parsed);
} catch {
// ignore invalid stored data
}
}
}
}, [searchParams]);
// Fetch live parts from backend (uses selected platform)
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoadingParts(true);
setPartsError(null);
const res = await fetch(
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}`,
{ signal: controller.signal },
);
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: Part[] = data
.map((p): Part | null => {
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
if (!categoryId) return null;
return {
id: p.id,
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined,
notes: undefined,
};
})
.filter(Boolean) as Part[];
setParts(normalized);
} catch (err: any) {
if (err?.name === "AbortError") return;
setPartsError(err?.message ?? "Failed to load products");
} finally {
setLoadingParts(false);
}
}
fetchProducts();
return () => controller.abort();
}, [platform]);
// Generate shareable URL (includes platform)
useEffect(() => {
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
const encoded = encodeBuildState(build);
const url = `${window.location.origin}/builder/build?platform=${encodeURIComponent(
platform,
)}&build=${encoded}`;
setShareUrl(url);
} else if (typeof window !== "undefined") {
setShareUrl("");
}
}, [build, platform]);
const selectedParts: Part[] = useMemo(() => {
if (parts.length === 0) return [];
return Object.entries(build)
.map(([categoryId, partId]) =>
parts.find(
(p) =>
p.id === partId && p.categoryId === (categoryId as CategoryId),
),
)
.filter(Boolean) as Part[];
}, [build, parts]);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts],
);
const handleCopyLink = async () => {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const handleShare = async () => {
if (!shareUrl) return;
if (navigator.share) {
try {
await navigator.share({
title: `My ${platform} Build`,
text: `Check out my ${platform} build: $${totalPrice.toFixed(2)}`,
url: shareUrl,
});
return;
} catch {
// fall back to copy
}
}
await handleCopyLink();
};
if (!loadingParts && selectedParts.length === 0) {
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-4xl px-4 py-6 lg:py-10">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
No Build Selected
</h1>
<p className="text-sm text-zinc-400 mb-6">
You need to select at least one part to view your build.
</p>
<Link
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Go to Builder
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<Link
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Build <span className="text-amber-300">Summary</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Review your build, purchase parts from our affiliate partners, or
share your build with others.
</p>
<p className="mt-2 text-xs text-zinc-500">
Platform: <span className="text-zinc-200">{platform}</span>
</p>
</div>
<div className="mt-3 md:mt-0">
<div className="text-xs text-zinc-400">Total Build Price</div>
<div className="text-3xl font-semibold text-amber-300">
${totalPrice.toFixed(2)}
</div>
<div className="text-xs text-zinc-500">
{selectedParts.length} / {CATEGORIES.length} parts selected
</div>
</div>
</div>
</header>
{/* Share Section */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/60 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400 mb-2">
Share Your Build
</h2>
<p className="text-sm text-zinc-400">
Share this link to let others view your build or bookmark it to
come back later.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleShare}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Share
</button>
<button
type="button"
onClick={handleCopyLink}
className={`rounded-md border px-4 py-2 text-sm font-medium transition-colors ${
copied
? "border-green-500 bg-green-500/10 text-green-400"
: "border-zinc-700 bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{copied ? "Copied!" : "Copy Link"}
</button>
</div>
</div>
{shareUrl && (
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
<p className="text-xs text-zinc-400 break-all font-mono">
{shareUrl}
</p>
</div>
)}
</section>
{/* Build Parts */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400 mb-4">
Selected Parts
</h2>
{loadingParts && (
<p className="text-sm text-zinc-500 mb-4">Loading parts</p>
)}
{partsError && (
<p className="text-sm text-red-400 mb-4">
{partsError} check the Ballistic API.
</p>
)}
<div className="space-y-4">
{CATEGORIES.map((category) => {
const selectedPartId = build[category.id];
const part = parts.find(
(p) =>
p.id === selectedPartId &&
p.categoryId === (category.id as CategoryId),
);
const partRole =
(CATEGORY_TO_PART_ROLE[category.id] as string | undefined) ??
"unknown";
return (
<div
key={category.id}
className="border border-zinc-800 rounded-md p-4 bg-zinc-900/30"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
{category.name}
</div>
{part ? (
<>
<div className="text-lg font-semibold text-zinc-50 mb-1">
<span className="text-zinc-400">{part.brand}</span>{" "}
{part.name}
</div>
<div className="mt-3 flex gap-2 flex-wrap">
{part.url && (
<a
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Purchase from Retailer
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)}
<Link
href={buildDetailHref({
id: part.id,
name: part.name,
platform,
partRole,
} as any)}
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
View Details
</Link>
</div>
</>
) : (
<div className="text-sm text-zinc-600">Not selected</div>
)}
</div>
{part && (
<div className="text-right">
<div className="text-2xl font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</section>
{/* Actions */}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
<Link
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
>
Edit Build
</Link>
<div className="flex gap-3">
<button
type="button"
onClick={() => {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
router.push(`/builder?platform=${encodeURIComponent(platform)}`);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Clear Build
</button>
</div>
</div>
</div>
</main>
);
}
+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>
);
}
+346
View File
@@ -0,0 +1,346 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchEmailRequests,
createEmailRequest,
updateEmailRequest,
deleteEmailRequest,
type EmailRequest,
type CreateEmailRequestDto,
type UpdateEmailRequestDto,
} from "@/lib/api/emailRequests";
import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/dateFormattingUtility";
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
const [formData, setFormData] = useState<CreateEmailRequestDto>({
email: "",
status: "",
});
const [submitting, setSubmitting] = useState(false);
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
const loadEmails = async () => {
if (!token) return;
try {
setLoading(true);
const data = await fetchEmailRequests(token);
setEmails(data.data);
setError(null);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load email requests");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadEmails();
}, [token]);
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
const counts = useMemo(() => {
const c = { ALL: 0, PENDING: 0, SENT: 0, FAILED: 0, OTHER: 0 } as Record<
EmailStatusTab,
number
>;
c.ALL = emails.length;
for (const e of emails) {
const st = normalizeStatus(e.status);
if (st === "PENDING") c.PENDING += 1;
else if (st === "SENT") c.SENT += 1;
else if (st === "FAILED") c.FAILED += 1;
else c.OTHER += 1;
}
return c;
}, [emails]);
const filteredEmails = useMemo(() => {
if (statusTab === "ALL") return emails;
return emails.filter((e) => {
const st = normalizeStatus(e.status);
if (statusTab === "OTHER") {
return st !== "PENDING" && st !== "SENT" && st !== "FAILED";
}
return st === statusTab;
});
}, [emails, statusTab]);
const tabs: { key: EmailStatusTab; label: string }[] = [
{ key: "ALL", label: "All" },
{ key: "PENDING", label: "Pending" },
{ key: "SENT", label: "Sent" },
{ key: "FAILED", label: "Failed" },
{ key: "OTHER", label: "Other" },
];
const handleCreate = () => {
setModalMode("create");
setFormData({ email: "", status: "" });
setSelectedEmail(null);
setShowModal(true);
};
const handleEdit = (email: EmailRequest) => {
setModalMode("edit");
setFormData({ email: email.email, status: email.status });
setSelectedEmail(email);
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return;
try {
await deleteEmailRequest(token, id);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to delete email request");
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
try {
setSubmitting(true);
if (modalMode === "create") {
await createEmailRequest(token, formData);
} else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData);
}
setShowModal(false);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to save email request");
} finally {
setSubmitting(false);
}
};
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return (
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">Emails</h1>
<p className="mt-1 text-xs text-zinc-400">
Manage Email and their statuses.
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
>
<Plus size={14} />
Add Email
</button>
</div>
{/* Status Tabs */}
<div className="flex flex-wrap items-center gap-2">
{tabs.map((t) => {
const active = statusTab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setStatusTab(t.key)}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs transition ${
active
? "border-amber-500/60 bg-amber-500/10 text-amber-200"
: "border-zinc-800 bg-zinc-950/60 text-zinc-300 hover:border-amber-400/60 hover:text-amber-300"
}`}
>
<span>{t.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] ${
active ? "bg-amber-500/15 text-amber-200" : "bg-zinc-900 text-zinc-400"
}`}
>
{counts[t.key]}
</span>
</button>
);
})}
</div>
{/* Error */}
{error && (
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{/* Table */}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-900/80 text-zinc-400">
<tr>
<th className="px-3 py-2 font-medium">ID</th>
<th className="px-3 py-2 font-medium">Email</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Created At</th>
<th className="px-3 py-2 font-medium">Updated At</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{filteredEmails.map((email) => (
<tr
key={email.id}
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
>
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
<td className="px-3 py-2">
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
{email.status}
</span>
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{new Date(email.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{email.updatedAt ? formatDate(email.updatedAt) : "—"}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(email)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(email.id)}
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{filteredEmails.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-4 text-center text-xs text-zinc-500"
>
No email requests found for{" "}
<span className="text-zinc-300">{tabs.find((t) => t.key === statusTab)?.label}</span>.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
</h2>
<button
onClick={() => setShowModal(false)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Status
</label>
<input
type="text"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="pending"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={submitting}
>
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+3 -3
View File
@@ -12,7 +12,7 @@ import {
type UpdateEmailRequestDto, type UpdateEmailRequestDto,
} from "@/lib/api/emailRequests"; } from "@/lib/api/emailRequests";
import { Pencil, Trash2, Plus, X } from "lucide-react"; import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/api/dateFormattingUtility"; import { formatDate } from "@/lib/dateFormattingUtility";
export default function AdminEmailPage() { export default function AdminEmailPage() {
const { token } = useAuth(); const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]); const [emails, setEmails] = useState<EmailRequest[]>([]);
@@ -110,7 +110,7 @@ export default function AdminEmailPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-lg font-semibold tracking-tight"> <h1 className="text-lg font-semibold tracking-tight">
Email Requests Processed E-mail
</h1> </h1>
<p className="mt-1 text-xs text-zinc-400"> <p className="mt-1 text-xs text-zinc-400">
Manage email requests and their statuses. Manage email requests and their statuses.
@@ -155,7 +155,7 @@ export default function AdminEmailPage() {
className="border-t border-zinc-800/70 hover:bg-zinc-900/60" className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
> >
<td className="px-3 py-2 text-zinc-400">{email.id}</td> <td className="px-3 py-2 text-zinc-400">{email.id}</td>
<td className="px-3 py-2 text-zinc-100">{email.email}</td> <td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300"> <span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
{email.status} {email.status}
@@ -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>
); );
} }
+3 -3
View File
@@ -22,7 +22,7 @@ type PlatformOption = {
}; };
// Backend currently filters products by canonical platform strings like "AR-15". // Backend currently filters products by canonical platform strings like "AR-15".
// DB platform keys are like "AR15" / "AR9". Translate when calling /api/products. // DB platform keys are like "AR15" / "AR9". Translate when calling /api/v1/products.
const platformKeyToApiPlatform = (key: string) => { const platformKeyToApiPlatform = (key: string) => {
const k = (key ?? "").toUpperCase().trim(); const k = (key ?? "").toUpperCase().trim();
if (!k) return "AR-15"; if (!k) return "AR-15";
@@ -123,7 +123,7 @@ export default function AdminProductsPage() {
const apiPlatform = platformKeyToApiPlatform(platform); const apiPlatform = platformKeyToApiPlatform(platform);
// NOTE: backend endpoint expects `platform`, not `platformKey`. // NOTE: backend endpoint expects `platform`, not `platformKey`.
const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent( const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
apiPlatform apiPlatform
)}`; )}`;
@@ -402,7 +402,7 @@ export default function AdminProductsPage() {
)} )}
<div className="mt-3 text-[0.7rem] text-zinc-500"> <div className="mt-3 text-[0.7rem] text-zinc-500">
Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span> Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/v1/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
</div> </div>
</section> </section>
</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>
);
}
+290 -175
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">
@@ -136,11 +217,12 @@ export default function BuildsPage() {
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>
+30 -6
View File
@@ -1,8 +1,9 @@
// app/layout.tsx // app/layout.tsx
import "./globals.css"; import "./globals.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext"; import Script from "next/script";
import { AuthProvider } from "@/context/AuthContext";
import { Banner } from "@/components/Banner"; import { Banner } from "@/components/Banner";
export const metadata = { export const metadata = {
@@ -11,18 +12,41 @@ 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 = `
(function () {
try {
const storedTheme = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = storedTheme || (prefersDark ? "dark" : "light");
document.documentElement.classList.toggle("dark", theme === "dark");
} catch (_) {}
})();
`;
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning className="dark">
<body className="bg-black text-zinc-50"> <head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<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}
</AuthProvider> </AuthProvider>
</body> </body>
</html> </html>
); );
+149 -48
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>
+30 -57
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,69 +11,41 @@ import {
Users, Users,
Settings, Settings,
LucideMail, LucideMail,
Wand2,
} from "lucide-react"; } from "lucide-react";
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: "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" />,
}, },
]; ];
export default function AdminLeftNavigation({ export default function AdminLeftNavigation({
collapsed, collapsed,
onToggleCollapsed, onToggleCollapsed,
items = defaultNavItems, // ✅ NEW default
}: AdminLeftNavigationProps) { }: AdminLeftNavigationProps) {
return ( return (
<aside <aside
@@ -97,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">
Battl Builders <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>
@@ -105,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"
@@ -116,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>
+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>
+110 -27
View File
@@ -1,15 +1,34 @@
"use client"; "use client";
/**
* BuilderNav.tsx
* -----------------------------------------------------------------------------
* Top navigation for the Builder experience.
*
* MAJOR LAYOUT IDEA:
* - Keep all primary nav items on the LEFT
* - Put *everything else* (search, viewing state, theme toggle later, user menu later)
* into a RIGHT "actions cluster" using a single `ml-auto` wrapper.
*
* WHY:
* - Prevents multiple `ml-auto` elements fighting each other
* - Makes the right side easy to extend without breaking alignment
*/
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import type { Category } from "@/types/gunbuilder"; import type { Category } from "@/types/gunbuilder";
type BuilderNavProps = { type BuilderNavProps = {
// Optional override if a parent wants to force the active category
activeCategoryId?: string | null; activeCategoryId?: string | null;
}; };
// Helper to group categories for the dropdowns /**
* Helper: group categories for dropdowns.
* This assumes your Category has a `group` field like "lower" | "upper" | "accessories".
*/
function groupCategories(group: "lower" | "upper" | "accessories"): Category[] { function groupCategories(group: "lower" | "upper" | "accessories"): Category[] {
return CATEGORIES.filter((c) => c.group === group); return CATEGORIES.filter((c) => c.group === group);
} }
@@ -17,32 +36,45 @@ function groupCategories(group: "lower" | "upper" | "accessories"): Category[] {
export function BuilderNav({ activeCategoryId }: BuilderNavProps) { export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// ---- Data / derived state --------------------------------------------------
const lower = groupCategories("lower"); const lower = groupCategories("lower");
const upper = groupCategories("upper"); const upper = groupCategories("upper");
const accessories = groupCategories("accessories"); const accessories = groupCategories("accessories");
// Determine which category is "active" so we can highlight dropdown items
// and show the "Viewing" indicator on the right.
const currentCategory = const currentCategory =
activeCategoryId ?? searchParams.get("category") ?? undefined; activeCategoryId ?? searchParams.get("category") ?? undefined;
// Base route for parts browsing (used for dropdown links + "Clear")
const baseHref = "/parts"; const baseHref = "/parts";
/**
* Dropdown renderer (hover-based).
* - Uses Tailwind `group` + `group-hover` to show/hide the menu.
* - Highlights the active item.
*/
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 */}
<button <button
type="button" type="button"
className={`inline-flex items-center gap-1 font-medium tracking-wide transition-colors uppercase text-[11px] ${ className={`inline-flex items-center gap-1 font-medium tracking-wide uppercase text-[11px] transition-colors ${
items.some(cat => cat.id === currentCategory) items.some((cat) => cat.id === currentCategory)
? "text-white" ? "text-white"
: "text-neutral-200 hover:text-white" : "text-neutral-300 hover:text-white"
}`} }`}
> >
{label} {label}
<span className="text-[9px]"></span> <span className="text-[9px]"></span>
</button> </button>
{/* Dropdown menu */}
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all absolute left-0 mt-2 rounded-md border border-neutral-800 bg-black/95 shadow-xl z-40 min-w-[220px]"> <div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all absolute left-0 mt-2 rounded-md border border-neutral-800 bg-black/95 shadow-xl z-40 min-w-[220px]">
<ul className="py-2 text-sm"> <ul className="py-2 text-sm">
{items.map((cat) => { {items.map((cat) => {
@@ -50,13 +82,15 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
return ( return (
<li key={cat.id}> <li key={cat.id}>
<Link <Link
href={`${baseHref}/${cat.id}`} href={{
className={[ pathname: `${baseHref}/${cat.id}`,
"block w-full px-3 py-1.5 text-left text-sm transition-colors", query: platform ? { platform } : {},
}}
className={`block w-full px-3 py-1.5 transition-colors ${
isActive isActive
? "bg-neutral-800 text-white" ? "bg-neutral-800 text-white"
: "text-neutral-300 hover:bg-neutral-800 hover:text-white", : "text-neutral-300 hover:bg-neutral-800 hover:text-white"
].join(" ")} }`}
> >
{cat.name} {cat.name}
</Link> </Link>
@@ -70,11 +104,23 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
}; };
return ( return (
<div className="max-w-6xl mx-auto w-full"> {/* ← aligns with topnav */} /**
* Outer container matches TopNav width:
* - max-w-6xl mx-auto w-full ensures consistent alignment across app
*/
<div className="max-w-6xl mx-auto w-full">
{/**
* FLEX STRATEGY:
* - Everything before the RIGHT cluster is "left side"
* - Then a single `ml-auto` cluster pushes everything else to the right
*/}
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm backdrop-blur-sm"> <nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm backdrop-blur-sm">
{/* Primary builder link */} {/* ------------------------------------------------------------------ */}
{/* LEFT SIDE: primary nav */}
{/* ------------------------------------------------------------------ */}
<Link <Link
href={'/builder'} href="/builder"
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]" className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
> >
Builder Builder
@@ -91,22 +137,59 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
Builds Builds
</Link> </Link>
{/* Right → Current filter */} {/* ------------------------------------------------------------------ */}
{currentCategory && ( {/* RIGHT SIDE: actions + state */}
<div className="ml-auto flex items-center gap-2 text-neutral-400 text-xs"> {/* IMPORTANT: this is the ONLY `ml-auto` in the nav to avoid conflicts */}
<span className="uppercase tracking-wide text-[10px]">Viewing</span> {/* ------------------------------------------------------------------ */}
<span className="font-medium text-neutral-100"> <div className="ml-auto flex items-center gap-3">
{CATEGORIES.find((c) => c.id === currentCategory)?.name ?? {/* Search icon (placeholder action) */}
currentCategory} <button
</span> type="button"
<Link className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/60 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
href={baseHref} aria-label="Search (coming soon)"
className="rounded border border-neutral-700 px-2 py-0.5 text-[10px] uppercase tracking-wide hover:bg-neutral-800 hover:text-white" title="Search (coming soon)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
> >
Clear <circle cx="11" cy="11" r="6" />
</Link> <line x1="16.5" y1="16.5" x2="21" y2="21" />
</div> </svg>
)} </button>
{/* "Viewing" indicator (only shows when a category is active) */}
{currentCategory && (
<div className="flex items-center gap-2 text-neutral-400 text-xs">
<span className="uppercase tracking-wide text-[10px]">
Viewing
</span>
<span className="font-medium text-neutral-100">
{CATEGORIES.find((c) => c.id === currentCategory)?.name ??
currentCategory}
</span>
{/* Clears category filter -> go back to /parts */}
<Link
href={baseHref}
className="rounded border border-neutral-700 px-2 py-0.5 text-[10px] uppercase tracking-wide hover:bg-neutral-800 hover:text-white"
>
Clear
</Link>
</div>
)}
{/* FUTURE: drop in ThemeToggle, UserMenu, Notifications here */}
{/* <ThemeToggle /> */}
{/* <UserMenu /> */}
</div>
</nav> </nav>
</div> </div>
); );
+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[];
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { Sun, Moon } from "lucide-react";
type Theme = "light" | "dark";
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("dark");
const [mounted, setMounted] = useState(false);
useEffect(() => {
const stored = (localStorage.getItem("theme") as Theme | null) ?? "dark";
setTheme(stored);
setMounted(true);
}, []);
if (!mounted) return null; // prevents hydration mismatch
function toggle() {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.toggle("dark", next === "dark");
}
/**
* @TODO, the toggle doesn't look right, need to either fix or remove the toggle.
*/
return (
<button
type="button"
onClick={toggle}
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
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"
>
{theme === "dark" ? (
<>
<Sun className="h-4 w-4" />
Light
</>
) : (
<>
<Moon className="h-4 w-4" />
Dark
</>
)}
</button>
);
}
+51 -41
View File
@@ -3,64 +3,77 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import ThemeToggle from "@/components/ThemeToggle";
function NavLink({
href,
children,
title
}: {
href: string;
children: React.ReactNode;
title?: string;
}) {
const pathname = usePathname();
const active = pathname === href;
return (
<Link
href={href}
title={title}
className={[
"text-xs font-medium transition-colors",
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
].join(" ")}
>
{children}
</Link>
);
}
export function TopNav() { export function TopNav() {
const { user, logout, loading } = useAuth(); const { user, logout, loading } = useAuth();
const isAuthenticated = !!user; const isAuthenticated = !!user;
return ( return (
<header className="border-b border-zinc-800 bg-black/95 backdrop-blur"> <header className="border-b border-zinc-200 bg-white/95 dark:border-zinc-800 dark:bg-black/95 backdrop-blur">
{/* Main nav row */}
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2"> <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
{/* Left: Brand / Home */} {/* Left: Brand / Home */}
<Link <Link
href="/" href="/"
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-f.svg"
src="/battl/battl-logo-mark-2.svg" alt="Battl Builders Logo"
alt="Battl Builders Logo" width={260}
width={260} // adjust to taste height={48}
height={48} // adjust to taste className="block"
className="block" />
/>
</Link> </Link>
{/* Right side actions */} {/* Right side actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
{/* Search placeholder */} <ThemeToggle />
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/60 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
aria-label="Search (coming soon)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<circle cx="11" cy="11" r="6" />
<line x1="16.5" y1="16.5" x2="21" y2="21" />
</svg>
</button>
{loading ? ( {loading ? (
<span className="text-xs text-zinc-500">Checking session</span> <span className="text-xs text-zinc-500">Checking session</span>
) : isAuthenticated ? ( ) : isAuthenticated ? (
<> <>
<span className="hidden text-xs text-zinc-300 sm:inline"> <NavLink href="/vault" title={"Your builds"}>Vault</NavLink>
{/* Email/displayName links to Account */}
<Link
href="/account"
className="hidden text-xs text-zinc-300 hover:text-zinc-100 transition-colors sm:inline"
title="Account"
>
{user.displayName || user.email} {user.displayName || user.email}
</span> </Link>
<button <button
type="button" type="button"
onClick={logout} onClick={logout}
@@ -71,12 +84,8 @@ export function TopNav() {
</> </>
) : ( ) : (
<> <>
<Link <NavLink href="/login">Log In</NavLink>
href="/login"
className="text-xs font-medium text-zinc-400 hover:text-zinc-100 transition-colors"
>
Log In
</Link>
<Link <Link
href="/register" href="/register"
className="rounded-md border border-amber-400/70 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-200 hover:bg-amber-400/20 transition-colors" className="rounded-md border border-amber-400/70 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-200 hover:bg-amber-400/20 transition-colors"
@@ -90,3 +99,4 @@ export function TopNav() {
</header> </header>
); );
} }
export default TopNav;
+201 -132
View File
@@ -1,5 +1,3 @@
"use client";
/** /**
* PartsBrowseClient * PartsBrowseClient
* ----------------------------------------------------------------------------- * -----------------------------------------------------------------------------
@@ -10,8 +8,11 @@
* - 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";
@@ -20,7 +21,10 @@ import Pagination from "@/components/parts/Pagination";
import PlatformSwitcher from "@/components/parts/PlatformSwitcher"; import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; import {
PART_ROLE_TO_CATEGORY,
normalizePartRole,
} from "@/lib/catalogMappings";
type ViewMode = "card" | "list"; type ViewMode = "card" | "list";
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
@@ -43,13 +47,15 @@ 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;
}; };
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; const API_BASE_URL =
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) {
@@ -70,9 +76,9 @@ function toSlug(s: string) {
*/ */
function buildDetailHref(platform: string, partRole: string, p: UiPart) { function buildDetailHref(platform: string, partRole: string, p: UiPart) {
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`); const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${encodeURIComponent( return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
`${p.id}-${slug}` partRole
)}`; )}/${encodeURIComponent(`${p.id}-${slug}`)}`;
} }
export default function PartsBrowseClient(props: { export default function PartsBrowseClient(props: {
@@ -83,8 +89,15 @@ export default function PartsBrowseClient(props: {
}) { }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
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 =
props.platform ?? searchParams.get("platform") ?? "AR-15";
const effectivePlatform = 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");
@@ -94,14 +107,20 @@ 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<{ min: number | null; max: number | null }>({ const [priceRange, setPriceRange] = useState<{
min: number | null;
max: number | null;
}>({
min: null, min: null,
max: null, max: null,
}); });
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;
@@ -116,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/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");
@@ -148,10 +169,27 @@ 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);
}, [partRole, effectivePlatform, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]); }, [
partRole,
effectivePlatform,
brandFilter,
sortBy,
searchQuery,
priceRange,
inStockOnly,
]);
// Reset scroll on route change
useEffect(() => {
window.scrollTo({ top: 0, behavior: "auto" });
}, [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)))
@@ -161,33 +199,30 @@ export default function PartsBrowseClient(props: {
); );
const priceBounds = useMemo(() => { const priceBounds = useMemo(() => {
if (parts.length === 0) return { min: null as number | null, max: null as number | null }; if (!parts.length)
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
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: null as number | null, max: null as number | null };
} return {
min: Math.min(...parts.map((p) => p.price)),
return { min, max }; max: Math.max(...parts.map((p) => p.price)),
};
}, [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]);
@@ -195,28 +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((p) => p.price >= effectiveMin && p.price <= effectiveMax); result = result.filter((p) => p.price <= priceRange.max!);
}
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":
@@ -234,37 +263,36 @@ export default function PartsBrowseClient(props: {
} }
return result; return result;
}, [parts, brandFilter, sortBy, searchQuery, priceRange, priceBounds.min, priceBounds.max, inStockOnly]); }, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
const totalPages = useMemo( const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE));
() => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)), const paginatedParts = filteredParts.slice(
[filteredParts.length] (currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
); );
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 = props.subtitle ?? "Browse available parts pulled from your Ballistic backend."; const headingSubtitle = props.subtitle ?? "Browse available parts.";
// ✅ 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 =
PART_ROLE_TO_CATEGORY[partRole] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null; PART_ROLE_TO_CATEGORY[partRole] ??
PART_ROLE_TO_CATEGORY[normalizedRole] ??
null;
if (!categoryId) { if (!categoryId) {
alert(`No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.`); alert(
`No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.`
);
return; return;
} }
@@ -278,53 +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} <span className="text-amber-300">{effectivePlatform}</span> {headingTitle}{" "}
</h1> <span className="text-amber-300">{effectivePlatform}</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">{headingSubtitle}</p> <p className="mt-2 text-sm text-zinc-400 max-w-xl">
{headingSubtitle}
</p>
<div className="mt-3"> {!isBuilderMode && (
<PlatformSwitcher currentPlatform={effectivePlatform} partRole={partRole} preserveQuery /> <div className="mt-4 flex flex-wrap items-center gap-3">
</div> <PlatformSwitcher
</div> currentPlatform={effectivePlatform}
partRole={partRole}
preserveQuery
mode="browse"
/>
{!loading && !error && parts.length > 0 && ( <Link
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60"> href={`/builder?platform=${encodeURIComponent(
<button effectivePlatform
type="button" )}`}
onClick={() => setViewMode("card")} 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={`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 Start New Build
</button> </Link>
<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>
{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}
@@ -340,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}
@@ -352,29 +415,35 @@ export default function PartsBrowseClient(props: {
)} )}
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-zinc-500">Loading</p> <p className="py-8 text-center text-sm text-zinc-500">
) : error ? ( Loading
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p> </p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">{error}</p>
) : filteredParts.length === 0 ? ( ) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">No parts found for this role yet.</p> <p className="py-8 text-center text-sm text-zinc-500">
No parts found for this role yet.
</p>
) : ( ) : (
<PartsGrid <PartsGrid
viewMode={viewMode} viewMode={viewMode}
parts={paginatedParts} parts={paginatedParts}
buildDetailHref={(p) => buildDetailHref(effectivePlatform, partRole, p)} buildDetailHref={(p) =>
buildDetailHref(effectivePlatform, partRole, p)
}
onAddToBuild={handleAddToBuild} onAddToBuild={handleAddToBuild}
addLabel="Add to Build" addLabel="Add to Build"
/> />
)} )}
{!loading && !error && filteredParts.length > 0 && totalPages > 1 && ( {totalPages > 1 && (
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
onPrev={() => setCurrentPage((p) => Math.max(1, p - 1))} onPrev={() => setCurrentPage((p) => Math.max(1, p - 1))}
onNext={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} onNext={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
/> />
)} )}
</div> </div>
+46 -35
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;
@@ -19,7 +21,6 @@ export default function PartsGrid(props: {
parts: UiPart[]; parts: UiPart[];
buildDetailHref: (p: UiPart) => string; buildDetailHref: (p: UiPart) => string;
// NEW: optional Add-to-Build behavior
onAddToBuild?: (p: UiPart) => void; onAddToBuild?: (p: UiPart) => void;
addLabel?: string; addLabel?: string;
}) { }) {
@@ -32,14 +33,16 @@ export default function PartsGrid(props: {
{parts.map((part) => ( {parts.map((part) => (
<div <div
key={part.id} key={part.id}
className="group relative flex flex-col rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 hover:border-amber-400/60 hover:bg-amber-400/10" className="group relative flex flex-col 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="mb-3 flex items-center gap-2 justify-between"> <div className="mb-3 flex items-center justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-zinc-50 truncate"> <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>
<div className="whitespace-nowrap text-sm font-semibold text-amber-300"> <div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)} ${part.price.toFixed(2)}
</div> </div>
@@ -48,17 +51,16 @@ export default function PartsGrid(props: {
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<Link <Link
href={buildDetailHref(part)} href={buildDetailHref(part)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700" className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 hover:border-zinc-600 hover:bg-zinc-700"
> >
View Details View Details
</Link> </Link>
{/* NEW: Add to Build (preferred primary action if provided) */}
{onAddToBuild ? ( {onAddToBuild ? (
<button <button
type="button" type="button"
onClick={() => onAddToBuild(part)} onClick={() => onAddToBuild(part)}
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300 transition-colors" className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
> >
{addLabel} {addLabel}
</button> </button>
@@ -67,7 +69,7 @@ export default function PartsGrid(props: {
href={part.buyUrl} href={part.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300 transition-colors" className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
> >
Buy Buy
</a> </a>
@@ -80,7 +82,6 @@ export default function PartsGrid(props: {
</button> </button>
)} )}
</div> </div>
</div> </div>
))} ))}
</div> </div>
@@ -90,56 +91,67 @@ export default function PartsGrid(props: {
// LIST view // LIST view
return ( return (
<> <>
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid"> {/* Header (DESKTOP ONLY) */}
<span>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>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="pr-2 text-right">Actions</span> <span className="text-right">Actions</span>
</div> </div>
{/* Rows */}
<div className="space-y-2"> <div className="space-y-2">
{parts.map((part) => ( {parts.map((part) => (
<div <div
key={part.id} key={part.id}
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 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)_auto] 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">
<div className="min-w-0 flex-1"> {/* Part */}
<div className="text-sm font-semibold text-zinc-50 truncate"> <div className="min-w-0">
<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>
</div> {/* Dont think we need Brand in the grid */}
{/* <div className="mt-0.5 text-xs text-zinc-500 line-clamp-1">
<div className="text-xs text-zinc-400 md:text-left md:text-sm"> {part.brand}
{part.brand} {part.caliber ? (
<span className="text-zinc-600"> • {part.caliber}</span>
) : null}
</div> */}
</div>
{/* Caliber (desktop) */}
<div className="hidden md:block text-sm text-zinc-300 text-right">
{part.caliber ?? "—"}
</div> </div>
{/* Price */}
<div className="text-sm font-semibold text-amber-300 md:text-right"> <div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)} ${part.price.toFixed(2)}
</div> </div>
{/* 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 transition-colors 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 transition-colors" 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
href={part.buyUrl} href={part.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors" className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
> >
Buy Buy
</a> </a>
@@ -153,7 +165,6 @@ export default function PartsGrid(props: {
)} )}
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
+2 -2
View File
@@ -48,8 +48,8 @@ export default function PartsListPageClient(props: {
setError(null); setError(null);
// Your current list endpoint: // Your current list endpoint:
// GET /api/products?platform=AR-15&partRoles=upper-receiver // GET /api/v1/products?platform=AR-15&partRoles=upper-receiver
const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`; const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
const res = await fetch(url, { headers: { Accept: "application/json" } }); const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) { if (!res.ok) {
+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>
); );
} }
+6 -5
View File
@@ -38,10 +38,10 @@ async function fetchProductDetail(params: {
if (!id) throw new Error(`Invalid product URL slug: '${productSlug}' (could not parse id)`); if (!id) throw new Error(`Invalid product URL slug: '${productSlug}' (could not parse id)`);
// ---- Preferred (if you add it): GET /api/products/{id} // ---- Preferred (if you add it): GET /api/v1/products/{id}
// If it 404s, we fall back. // If it 404s, we fall back.
try { try {
const res = await fetch(`${API_BASE_URL}/api/products/${encodeURIComponent(id)}`, { const res = await fetch(`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}); });
@@ -54,7 +54,7 @@ async function fetchProductDetail(params: {
// ---- Fallback: use list endpoint + find by id (works right now with your current API) // ---- Fallback: use list endpoint + find by id (works right now with your current API)
const listRes = await fetch( const listRes = await fetch(
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`, `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
{ headers: { Accept: "application/json" } } { headers: { Accept: "application/json" } }
); );
@@ -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>
+2 -1
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import QuillImageResize from "quill-image-resize-module";
Quill.register("modules/imageResize", QuillImageResize);
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type React from "react"; import type React from "react";
+79 -32
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,12 +227,12 @@ 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) {
+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]);
} }
+1
View File
@@ -1,5 +1,6 @@
export interface EmailRequest { export interface EmailRequest {
id: number; id: number;
recipient: string;
email: string; email: string;
status: string; status: string;
createdAt: string; createdAt: string;
+5 -5
View File
@@ -42,7 +42,7 @@ export function slugify(input: string) {
/** /**
* Your existing list endpoint: * Your existing list endpoint:
* GET /api/products?platform=AR-15&partRoles=complete-upper * GET /api/v1/products?platform=AR-15&partRoles=complete-upper
*/ */
export async function fetchProducts(params: { export async function fetchProducts(params: {
platform: string; platform: string;
@@ -51,7 +51,7 @@ export async function fetchProducts(params: {
const { platform, partRole } = params; const { platform, partRole } = params;
const url = const url =
`${API_BASE_URL}/api/products?` + `${API_BASE_URL}/api/v1/products?` +
new URLSearchParams({ new URLSearchParams({
platform, platform,
partRoles: partRole, partRoles: partRole,
@@ -70,9 +70,9 @@ export async function fetchProducts(params: {
/** /**
* You likely have (or should add) a detail endpoint like: * You likely have (or should add) a detail endpoint like:
* GET /api/products/{id}?platform=AR-15 OR GET /api/products/{id} * GET /api/v1/products/{id}?platform=AR-15 OR GET /api/v1/products/{id}
* *
* We'll call /api/products/{id} and optionally include platform as a query param. * We'll call /api/v1/products/{id} and optionally include platform as a query param.
*/ */
export async function fetchProductById(params: { export async function fetchProductById(params: {
id: string; id: string;
@@ -81,7 +81,7 @@ export async function fetchProductById(params: {
const { id, platform } = params; const { id, platform } = params;
const url = const url =
`${API_BASE_URL}/api/products/${encodeURIComponent(id)}` + `${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}` +
(platform ? `?${new URLSearchParams({ platform })}` : ""); (platform ? `?${new URLSearchParams({ platform })}` : "");
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, { cache: "no-store" });
+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)).*)",
], ],
}; };
+63
View File
@@ -12,6 +12,7 @@
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"next": "14.2.3", "next": "14.2.3",
"quill": "^2.0.3", "quill": "^2.0.3",
"quill-image-resize-module": "^3.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-quill": "^2.0.0" "react-quill": "^2.0.0"
@@ -4712,6 +4713,68 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/quill-image-resize-module": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quill-image-resize-module/-/quill-image-resize-module-3.0.0.tgz",
"integrity": "sha512-1TZBnUxU/WIx5dPyVjQ9yN7C6mLZSp04HyWBEMqT320DIq4MW4JgzlOPDZX5ZpBM3bU6sacU4kTLUc8VgYQZYw==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.4",
"quill": "^1.2.2",
"raw-loader": "^0.5.1"
}
},
"node_modules/quill-image-resize-module/node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/quill-image-resize-module/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/quill-image-resize-module/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/quill-image-resize-module/node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-image-resize-module/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/raw-loader": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz",
"integrity": "sha512-sf7oGoLuaYAScB4VGr0tzetsYlS8EJH6qnTCfQ/WVEa89hALQ4RQfCKt5xCyPQKPDUbVUAIP1QsxAwfAjlDp7Q=="
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+1
View File
@@ -13,6 +13,7 @@
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"next": "14.2.3", "next": "14.2.3",
"quill": "^2.0.3", "quill": "^2.0.3",
"quill-image-resize-module": "^3.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-quill": "^2.0.0" "react-quill": "^2.0.0"
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

+5 -4
View File
@@ -3,11 +3,12 @@ import type { Config } from "tailwindcss";
const config: Config = { const config: Config = {
content: [ content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}" "./components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx}",
], ],
theme: { theme: { extend: {} },
extend: {},
},
plugins: [], plugins: [],
darkMode: "class",
}; };
export default config; export default config;