19 Commits

Author SHA1 Message Date
sean 8d7809ccf1 added stuff to builds/details 2025-12-26 21:04:33 -05:00
sean e202d811ed new builds/id page 2025-12-26 20:53:04 -05:00
sean c8bf032f7a set admin behind a cookie and check role on profile 2025-12-26 17:19:11 -05:00
sean 5401f6464d disable beta sign up email blasts 2025-12-24 08:34:39 -05:00
sean 57034eefc3 added enrichment ui and functionality 2025-12-23 11:18:34 -05:00
sean bb8ddb6823 small product grid cleanup 2025-12-23 06:17:34 -05:00
dstrawsb 70fac2857e fixing titles and stuff 2025-12-22 23:39:12 -05:00
sean c01aabb003 cleaned up product details page. added a link on builder to the selected product details page 2025-12-22 17:31:04 -05:00
sean 061d737c6c fixing platform switcher bugs 2025-12-22 09:03:32 -05:00
sean 34e915f904 fixed layouts, routing and the platform switcher. 2025-12-22 08:36:19 -05:00
sean b1a8dae8ed added favicon 2025-12-21 07:52:16 -05:00
sean 13edcdce69 fix possible mem leaks on photos 2025-12-21 07:40:45 -05:00
sean 856b99b933 allow users to upload build images. No backend, just local previews. need to build backend 2025-12-21 07:39:32 -05:00
sean 008936ff98 fixing vault. allow users to delete their builds or update 2025-12-21 07:20:22 -05:00
sean 47d5cf239d magic link support and forgot password 2025-12-20 18:58:25 -05:00
sean 8d9013d0dd beta sign up sends an email with a magic token. then a separate email to login. 2025-12-20 17:15:10 -05:00
sean c06696e66d fixed an vault edit error. 2025-12-20 12:24:40 -05:00
sean 54ea3243fd Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2025-12-20 10:01:22 -05:00
sean 61935982b3 new pages for user buils (vault), edit saved builds, and the community build page. 2025-12-20 10:01:07 -05:00
49 changed files with 4328 additions and 1148 deletions
+14
View File
@@ -0,0 +1,14 @@
// app/(account)/AccountChrome.tsx
"use client";
import TopNav from "@/components/TopNav";
import BuilderNav from "@/components/BuilderNav";
export default function AccountChrome() {
return (
<>
<TopNav />
<BuilderNav />
</>
);
}
+401 -6
View File
@@ -1,10 +1,58 @@
// 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 } = useAuth();
const { user, loading, token, setSession, logout } = useAuth();
const searchParams = useSearchParams();
const router = useRouter();
const welcome = searchParams.get("welcome") === "1";
const needsDisplayName = useMemo(() => {
return !user?.displayName || user.displayName.trim().length === 0;
}, [user?.displayName]);
// Only show welcome panel if they actually need setup
const showWelcomeCard = welcome && needsDisplayName;
// ----------------------------
// Display Name state (shared)
// ----------------------------
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
// Inline profile editor
const [editingName, setEditingName] = useState(false);
// ----------------------------
// Password state
// ----------------------------
const [showPassword, setShowPassword] = useState(false);
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
const canSaveName = useMemo(() => {
return displayName.trim().length > 0 && !saving;
}, [displayName, saving]);
const pwMismatch = useMemo(
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
[pw1, pw2]
);
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
const canSavePassword = useMemo(() => {
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
}, [pwSaving, pw1, pw2]);
if (loading) return <div className="text-sm opacity-70">Loading</div>;
@@ -15,29 +63,376 @@ export default function AccountPage() {
</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-4">
<div className="space-y-6">
{/* Welcome / Setup card (only if needed) */}
{showWelcomeCard && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
<div>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm opacity-70">
Basic account info (well expand this soon).
<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>
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-2">
{/* 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>
);
+22 -21
View File
@@ -1,56 +1,57 @@
// 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;
}) {
export default function AccountLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<div className="mx-auto max-w-6xl px-4 py-6">
<AccountChrome />
<div className="mx-auto w-full max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Account</h1>
<p className="text-sm opacity-70">
<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="text-sm opacity-80 hover:underline">
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
>
Back to Builder
</Link>
</div>
<div className="grid gap-6 md:grid-cols-[240px_1fr]">
<aside className="rounded-xl border border-white/10 bg-white/5 p-3">
<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 opacity-80 hover:bg-white/10 hover:opacity-100"
className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
>
{item.label}
</Link>
))}
<div className="my-2 border-t border-white/10" />
<Link
href="/vault"
className="rounded-lg px-3 py-2 text-sm opacity-80 hover:bg-white/10 hover:opacity-100"
>
My Vault
</Link>
</nav>
</aside>
<main className="rounded-xl border border-white/10 bg-white/5 p-4">
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
{children}
</main>
</div>
@@ -1,14 +1,36 @@
// app/(builder)/builder/page.tsx
"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 Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { X, Link2, Square, CheckSquare } from "lucide-react";
import { useApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
import { X, Link2, Square, CheckSquare } from "lucide-react";
// ✅ Centralized overlap rules + helpers
import OverlapChip from "@/components/builder/OverlapChip";
@@ -31,6 +53,11 @@ type GunbuilderProductFromApi = {
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 =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -114,10 +141,29 @@ export default function GunbuilderPage() {
const searchParams = useSearchParams();
const router = useRouter();
const api = useApi();
// ✅ Auth: we need the token ready before calling /builds/me/*
const { token, loading: authLoading, getAuthHeaders } = useAuth();
// -----------------------------
// Save As… modal state (Vault variants)
// -----------------------------
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// -----------------------------
// Platform state (AR-15/AR-10/AR-9)
// -----------------------------
const isValidPlatform = (
value: string | null
): value is (typeof PLATFORMS)[number] =>
@@ -129,6 +175,9 @@ export default function GunbuilderPage() {
return isValidPlatform(initial) ? initial : "AR-15";
});
// -----------------------------
// Build state (categoryId -> productId), persisted locally
// -----------------------------
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -143,6 +192,20 @@ export default function GunbuilderPage() {
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 [shareUrl, setShareUrl] = useState<string>("");
@@ -153,7 +216,9 @@ export default function GunbuilderPage() {
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
const processedActionKeyRef = useRef<string>("");
// ---------- Derived ----------
// -----------------------------
// Derived collections
// -----------------------------
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) {
@@ -177,8 +242,26 @@ export default function GunbuilderPage() {
});
}, [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
// NOTE: defensive while backend roles/mappings evolve
// -----------------------------
const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
if (normalizedRole === "upper-receiver") return "upper-receiver";
if (normalizedRole.includes("complete-upper")) return "complete-upper";
@@ -271,22 +354,9 @@ export default function GunbuilderPage() {
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
};
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]
);
// -----------------------------
// Selection handler (with hint warnings)
// -----------------------------
const handleSelectPart = useCallback(
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
const warn = (msg: string) => {
@@ -305,7 +375,9 @@ export default function GunbuilderPage() {
[build]
);
// ---------- Fetch products ----------
// -----------------------------
// Fetch products (scoped + universal merge)
// -----------------------------
useEffect(() => {
const controller = new AbortController();
@@ -317,7 +389,6 @@ export default function GunbuilderPage() {
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
platform
)}`;
const universalUrl = `${API_BASE_URL}/api/v1/products`;
const [scopedRes, universalRes] = await Promise.all([
@@ -350,6 +421,7 @@ export default function GunbuilderPage() {
const categoryId = resolveCategoryId(normalizedRole);
if (!categoryId) return null;
// Only accept universal fetch results for "universal" categories
if (
data === universalData &&
!UNIVERSAL_CATEGORIES.has(categoryId)
@@ -377,6 +449,7 @@ export default function GunbuilderPage() {
const scopedParts = normalize(scopedData);
const universalParts = normalize(universalData);
// Merge (avoid duplicates across calls)
const seen = new Set<string>();
const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) {
@@ -399,7 +472,9 @@ export default function GunbuilderPage() {
return () => controller.abort();
}, [platform]);
// ✅ Persist build state whenever it changes
// -----------------------------
// Persist build to localStorage
// -----------------------------
useEffect(() => {
if (typeof window === "undefined") return;
try {
@@ -409,7 +484,9 @@ export default function GunbuilderPage() {
}
}, [build]);
// When the platform changes, clear ONLY platform-scoped selections.
// -----------------------------
// When platform changes, clear ONLY platform-scoped selections
// -----------------------------
useEffect(() => {
const prev = lastPlatformRef.current;
lastPlatformRef.current = platform;
@@ -433,21 +510,43 @@ export default function GunbuilderPage() {
});
}, [platform]);
// ✅ Load a saved build from Vault via ?load=<uuid>
// -----------------------------
// Load a saved build from Vault via ?load=<uuid> OR legacy ?build=<uuid>
// -----------------------------
useEffect(() => {
const loadUuid = searchParams.get("load");
if (!loadUuid) return;
const buildParam = searchParams.get("build");
const uuidToLoad =
(loadUuid && isUuid(loadUuid) ? loadUuid : null) ||
(buildParam && isUuid(buildParam) ? buildParam : null);
if (!uuidToLoad) return;
// ✅ Wait for AuthProvider hydration before calling /me endpoints.
if (authLoading) return;
// ✅ If not logged in, don't spam the backend; show a helpful message instead.
if (!token) {
setShareStatus("Please log in to load builds from your Vault.");
window.setTimeout(() => setShareStatus(null), 4500);
return;
}
const run = async () => {
try {
setShareStatus("Loading build…");
// NOTE: your backend GET route is /api/v1/products/builds/{uuid}
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
// explicitly attach JWT headers.
const res = await fetch(
`${API_BASE_URL}/api/v1/products/builds/${encodeURIComponent(
loadUuid
)}`,
{ credentials: "include" }
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
{
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
}
);
if (!res.ok) {
@@ -466,10 +565,15 @@ export default function GunbuilderPage() {
setBuild(nextBuild);
setShareStatus(`Loaded: ${dto.title}`);
// Clean URL so reload doesn't re-fetch
// Clean URL so refresh doesn't re-load repeatedly
const qp = new URLSearchParams(searchParams.toString());
qp.delete("load");
router.replace(`/builder?${qp.toString()}`, { scroll: false });
if (buildParam && isUuid(buildParam)) qp.delete("build");
const next = qp.toString();
router.replace(next ? `/builder?${next}` : "/builder", {
scroll: false,
});
} catch (e: any) {
setShareStatus(e?.message ?? "Failed to load build");
} finally {
@@ -478,12 +582,90 @@ export default function GunbuilderPage() {
};
run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, router]);
}, [
searchParams,
router,
authLoading,
token,
getAuthHeaders,
// API_BASE_URL is a module constant; no need to include
]);
// -----------------------------
// SAVE AS… (single source of truth for saving to Vault)
// - 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:
// - ?select=categoryId:partId
// - ?remove=categoryId
// -----------------------------
useEffect(() => {
const selectParam = searchParams.get("select");
const removeParam = searchParams.get("remove");
@@ -522,12 +704,11 @@ export default function GunbuilderPage() {
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
// Keep URL clean/stable
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
if (typeof window !== "undefined") {
const current = `${window.location.pathname}${window.location.search}`;
if (current !== nextUrl) {
router.replace(nextUrl, { scroll: false });
}
if (current !== nextUrl) router.replace(nextUrl, { scroll: false });
} else {
router.replace(nextUrl, { scroll: false });
}
@@ -541,7 +722,7 @@ export default function GunbuilderPage() {
}
}, [searchParams, platform]);
// Build share URL whenever build changes
// Build share URL whenever build changes (client-side only)
useEffect(() => {
if (typeof window === "undefined") return;
@@ -571,53 +752,7 @@ export default function GunbuilderPage() {
[]
);
// Handler to Save user build to account
const handleSaveToAccount = async () => {
if (selectedParts.length === 0) {
setShareStatus("Add a few parts before saving.");
return;
}
const title = `${platform} Build`;
const items = Object.entries(build)
.filter(([, partId]) => !!partId)
.map(([categoryId, partId]) => ({
productId: Number(partId),
slot: categoryId,
position: 0,
quantity: 1,
}));
try {
setShareStatus("Saving build…");
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title,
description: null,
isPublic: false,
items,
}),
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`Save failed (${res.status}) ${txt}`);
}
const saved = await res.json(); // BuildDto
setShareStatus(`Saved! Build UUID: ${saved.uuid}`);
} catch (e: any) {
setShareStatus(e?.message ?? "Save failed");
} finally {
window.setTimeout(() => setShareStatus(null), 4500);
}
};
// Copy summary text to clipboard
const handleShare = async () => {
if (selectedParts.length === 0) {
setShareStatus("Add a few parts before sharing your build.");
@@ -625,7 +760,7 @@ export default function GunbuilderPage() {
}
const lines: string[] = [];
lines.push("B Build");
lines.push("Battl Build");
lines.push("");
lines.push(`Total: $${totalPrice.toFixed(2)}`);
lines.push("");
@@ -685,8 +820,8 @@ export default function GunbuilderPage() {
if (navigator.share) {
try {
await navigator.share({
title: "Shadow Standard — The Armory Build",
text: "Check out my Shadow Standard Armory build.",
title: "BATTL — The Builder",
text: "Check out my Battl Build.",
url: shareUrl,
});
setShareStatus("Share dialog opened.");
@@ -702,6 +837,99 @@ export default function GunbuilderPage() {
return (
<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">
{/* Header */}
<header className="mb-6">
@@ -710,7 +938,7 @@ export default function GunbuilderPage() {
BATTL BUILDERS
</p>
<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>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Explore components from trusted brands, choose one part per
@@ -728,6 +956,7 @@ export default function GunbuilderPage() {
const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next);
// Keep URL in sync, and clear transient action params
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next);
qp.delete("select");
@@ -760,6 +989,7 @@ export default function GunbuilderPage() {
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
My Build Breakdown
</h2>
<div>
<div className="text-xs text-zinc-400">Current build total</div>
<div className="text-2xl font-semibold text-amber-300">
@@ -787,10 +1017,12 @@ export default function GunbuilderPage() {
<button
type="button"
onClick={() => {
// Reset local build state
setBuild({});
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
showToast({ type: "info", message: "Build cleared." });
}}
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 ${
@@ -802,10 +1034,10 @@ export default function GunbuilderPage() {
Clear Build
</button>
{/* Save Build */}
{/* Save As… triggers modal */}
<button
type="button"
onClick={handleSaveToAccount}
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
@@ -813,8 +1045,16 @@ export default function GunbuilderPage() {
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
}`}
>
Save Build
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>
@@ -829,6 +1069,7 @@ export default function GunbuilderPage() {
Share this link to let others view your build or bookmark it
to come back later.
</p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1">
<div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
@@ -841,6 +1082,7 @@ export default function GunbuilderPage() {
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<div className="flex gap-2 md:flex-none md:mt-5">
<button
type="button"
@@ -849,6 +1091,7 @@ export default function GunbuilderPage() {
>
Share
</button>
<button
type="button"
onClick={handleCopyLink}
@@ -968,12 +1211,14 @@ export default function GunbuilderPage() {
<tbody>
{groupCategories.map((category) => {
const categoryParts = partsByCategory[category.id] ?? [];
const categoryParts =
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id];
const selectedPart = selectedPartId
? categoryParts.find((p) => p.id === selectedPartId) ??
parts.find((p) => p.id === selectedPartId)
? categoryParts.find(
(p) => p.id === selectedPartId
) ?? parts.find((p) => p.id === selectedPartId)
: undefined;
const hasParts = categoryParts.length > 0;
@@ -992,10 +1237,27 @@ export default function GunbuilderPage() {
{selectedPart ? (
<>
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
<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>
{/* ✅ Category overlap chips */}
{/* Overlap chips help explain conflicts / dependencies */}
<div className="mt-1 flex flex-wrap gap-1.5">
{getOverlapChipsForCategory(
category.id as CategoryId,
@@ -1038,7 +1300,7 @@ export default function GunbuilderPage() {
</td>
{/* Caliber placeholder */}
{/* Caliber placeholder (future: derive from build profile) */}
<td className="px-3 py-2 align-top text-zinc-400">
</td>
@@ -1077,10 +1339,9 @@ export default function GunbuilderPage() {
</>
) : hasParts ? (
<Link
href={{
pathname: `/parts/p/${platform}/${category.id}`,
query: platform ? { platform } : {},
}}
href={`/parts/p/${encodeURIComponent(
platform
)}/${encodeURIComponent(category.id)}`}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
<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
/>
);
}
@@ -204,7 +204,7 @@ export default function ProductDetailsPage() {
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="hover:text-zinc-300"
>
The Armory
The Builder
</Link>
<span className="text-zinc-700">/</span>
<Link
@@ -370,7 +370,7 @@ export default function CategoryPage() {
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
Back to the Builder
</Link>
<div className="flex items-start justify-between gap-4">
<div>
@@ -5,19 +5,18 @@ import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
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 returns in offers[]
* - GunbuilderProductFromApi: the product detail contract (v1)
* - OfferFromApi: what /api/v1/products/:id/offers returns
* - GunbuilderProductFromApi: product detail contract (v1)
*/
type OfferFromApi = {
id?: string | number;
merchantName?: string | null;
price?: number | null;
originalPrice?: number | null;
buyUrl?: string | null;
inStock?: boolean | null;
lastUpdated?: string | null;
@@ -43,7 +42,7 @@ type GunbuilderProductFromApi = {
shortDescription?: string | null;
description?: string | null;
// New: offers[] from the API
// (May be empty depending on endpoint; we now fetch offers separately)
offers?: OfferFromApi[] | null;
};
@@ -72,11 +71,44 @@ function formatPrice(price: number | null | undefined): string {
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 name tiebreaker
* 3) merchant label tiebreaker
*/
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
return [...offers].sort((a, b) => {
@@ -89,7 +121,7 @@ function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
const bStock = b.inStock === false ? 1 : 0;
if (aStock !== bStock) return aStock - bStock;
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
return merchantLabel(a).localeCompare(merchantLabel(b));
});
}
@@ -153,9 +185,7 @@ export default function ProductDetailsPage() {
return () => window.removeEventListener("storage", onStorage);
}, []);
const selectedPartIdForCategory = categoryId
? build?.[categoryId]
: undefined;
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
const isSelected =
!!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : "");
@@ -165,6 +195,13 @@ export default function ProductDetailsPage() {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Offers fetch state (from /products/:id/offers)
const [offersFromApi, setOffersFromApi] = useState<OfferFromApi[]>([]);
const [offersLoading, setOffersLoading] = useState(false);
// Image modal state
const [isImageOpen, setIsImageOpen] = useState(false);
/**
* Route normalization:
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
@@ -181,14 +218,7 @@ export default function ProductDetailsPage() {
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
platformParam,
platform,
partRoleParam,
productSlug,
router,
searchParams,
]);
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
/**
* Fetch product details from API:
@@ -229,6 +259,41 @@ export default function ProductDetailsPage() {
return () => controller.abort();
}, [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
@@ -261,19 +326,16 @@ export default function ProductDetailsPage() {
/**
* Offers normalization:
* - If product.offers exists, use & sort it (real offers).
* - Otherwise, fallback to a single offer derived from product.buyUrl/product.price (legacy).
* - Prefer offers endpoint data
* - Fallback to legacy single-offer derived from product if needed
*/
const offers = useMemo(() => {
if (!product) return [];
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[];
if (raw.length > 0) return sortOffers(raw);
if (product.buyUrl) {
if (product?.buyUrl) {
return sortOffers([
{
merchantName: product.merchantName ?? "Merchant",
merchantName: product.merchantName ?? "Retailer",
price: product.price,
buyUrl: product.buyUrl,
inStock: product.inStock ?? true,
@@ -282,21 +344,30 @@ export default function ProductDetailsPage() {
}
return [];
}, [product]);
}, [offersFromApi, product]);
/**
* Best offer (for the "Buy from ..." CTA)
* IMPORTANT: this must be defined AFTER offers is computed
* (do NOT reference `offers` inside the offers useMemo).
* Best offer (sorted offers[0])
*/
const bestOffer = offers[0] ?? null;
/**
* Helper: identify the best offer row
* We compare by buyUrl (stable + unique enough for MVP)
*/
const isBestOffer = (offer: OfferFromApi) =>
!!bestOffer && offer.buyUrl && offer.buyUrl === bestOffer.buyUrl;
!!bestOffer &&
offer.id != null &&
bestOffer.id != null &&
String(offer.id) === String(bestOffer.id);
useEffect(() => {
console.log("OFFERS", offers);
console.log("BEST", bestOffer);
}, [offers, bestOffer]);
// Use best offer for “truth” when available
const stockValue = bestOffer?.inStock ?? product?.inStock ?? null;
const currentPrice = bestOffer?.price ?? product?.price ?? null;
if (!categoryId) {
return (
@@ -366,35 +437,6 @@ export default function ProductDetailsPage() {
canonical /parts/p route.
</p>
</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>
</header>
@@ -414,18 +456,25 @@ export default function ProductDetailsPage() {
</p>
) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* Left: image + core actions */}
{/* Left */}
<div className="space-y-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">
{imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<button
type="button"
onClick={() => setIsImageOpen(true)}
className="h-full w-full"
title="Click to enlarge"
>
{/* 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">
No image
@@ -439,12 +488,12 @@ export default function ProductDetailsPage() {
</span>
<span
className={`text-xs font-semibold ${
product.inStock === false
stockValue === false
? "text-red-300"
: "text-emerald-300"
}`}
>
{product.inStock === false ? "Out of stock" : "In stock"}
{stockValue === false ? "Out of stock" : "In stock"}
</span>
</div>
@@ -467,20 +516,18 @@ export default function ProductDetailsPage() {
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove" : "Add"}
{isSelected ? "Remove" : "Add to Build"}
</button>
</div>
</div>
{/* Pricing history placeholder */}
{/* Price history placeholder */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Price History
</h3>
<span className="text-[10px] text-zinc-600">
placeholder
</span>
<span className="text-[10px] text-zinc-600">placeholder</span>
</div>
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
Pricing graph will go here
@@ -492,7 +539,7 @@ export default function ProductDetailsPage() {
</div>
</div>
{/* Right: details + offers */}
{/* Right */}
<div className="min-w-0 space-y-4">
{/* Product meta */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
@@ -532,7 +579,7 @@ export default function ProductDetailsPage() {
Current price
</div>
<div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(product.price)}
{formatPrice(currentPrice)}
</div>
</div>
</div>
@@ -560,7 +607,11 @@ export default function ProductDetailsPage() {
Merchant Offers
</h3>
<span className="text-[10px] text-zinc-600">
{offers.length ? `${offers.length} offers` : "no offers"}
{offersLoading
? "loading…"
: offers.length
? `${offers.length} offers`
: "no offers"}
</span>
</div>
@@ -578,20 +629,19 @@ export default function ProductDetailsPage() {
<tbody className="divide-y divide-zinc-800">
{offers.map((o, idx) => (
<tr
key={idx}
key={String(o.id ?? idx)}
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>{o.merchantName ?? "Merchant"}</span>
<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
BEST DEAL
</span>
)}
</td>
@@ -607,9 +657,7 @@ export default function ProductDetailsPage() {
)}
</td>
<td className="px-3 py-2 text-right font-semibold text-amber-300">
{o.price != null
? `$${o.price.toFixed(2)}`
: "—"}
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td>
<td className="px-3 py-2 text-right">
{o.buyUrl ? (
@@ -622,9 +670,7 @@ export default function ProductDetailsPage() {
Buy
</a>
) : (
<span className="text-xs text-zinc-600">
</span>
<span className="text-xs text-zinc-600"></span>
)}
</td>
</tr>
@@ -639,7 +685,7 @@ export default function ProductDetailsPage() {
</p>
)}
{/* Best Offer CTA (uses sorted offers[0]) */}
{/* Best Offer CTA */}
{bestOffer?.buyUrl ? (
<div className="mt-3 flex justify-end">
<a
@@ -648,7 +694,7 @@ export default function ProductDetailsPage() {
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"
>
Buy from {bestOffer.merchantName ?? "Merchant"}
Buy from {merchantLabel(bestOffer)}
</a>
</div>
) : null}
@@ -656,20 +702,48 @@ export default function ProductDetailsPage() {
{/* Footer tips */}
<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.
</span>
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
</span> */}
{/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Canonical route:{" "}
<span className="text-zinc-300">
/parts/p/{platform}/{partRoleParam}/{productSlug}
</span>
</span>
</span> */}
</div>
</div>
</div>
)}
</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>
</main>
);
+616
View File
@@ -0,0 +1,616 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = {
uuid: string;
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
items?: Array<{
slot: string;
productId: number;
productName?: string | null;
productBrand?: string | null;
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
}>;
photos?: BuildPhotoDto[];
};
type BuildPhotoDto = {
uuid: string;
url: string;
caption?: string | null;
sortOrder?: number | null;
createdAt?: string | null;
};
type LocalPreview = {
id: string;
file: File;
url: string; // object URL
};
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
}
export default function VaultBuildEditPage() {
const { uuid } = useParams<{ uuid: string }>();
const router = useRouter();
const api = useApi();
const { token, loading: authLoading } = useAuth();
const [build, setBuild] = useState<BuildDto | null>(null);
const [form, setForm] = useState({
title: "",
description: "",
isPublic: false,
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
// photo upload UI state
const [previews, setPreviews] = useState<LocalPreview[]>([]);
const [uploading, setUploading] = useState(false);
// avoid object URL leaks
useEffect(() => {
return () => {
previews.forEach((p) => URL.revokeObjectURL(p.url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const authed = !!token;
// Load build once auth is ready + token exists
useEffect(() => {
if (!uuid) return;
if (authLoading) return;
if (!authed) {
setError("Please log in to edit builds.");
return;
}
let cancelled = false;
(async () => {
try {
setError(null);
const res = await api.get(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load build (${res.status})`);
}
const data = (await res.json()) as BuildDto;
if (cancelled) return;
setBuild(data);
setForm({
title: data.title ?? "",
description: data.description ?? "",
isPublic: Boolean(data.isPublic),
});
} catch (e: any) {
if (!cancelled) setError(e?.message ?? "Failed to load build");
}
})();
return () => {
cancelled = true;
};
}, [uuid, authLoading, authed, api]);
const dirty = useMemo(() => {
if (!build) return false;
return (
form.title !== (build.title ?? "") ||
form.description !== (build.description ?? "") ||
form.isPublic !== Boolean(build.isPublic)
);
}, [build, form]);
async function onSave() {
if (!uuid) return;
setBusy(true);
setSavedMsg(null);
setError(null);
try {
const payload = {
title: form.title.trim() || "Untitled Build",
description: form.description.trim() || null,
isPublic: form.isPublic,
};
const res = await api.put(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`,
payload
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Save failed (${res.status})`);
}
const updated = (await res.json()) as BuildDto;
setBuild(updated);
setForm({
title: updated.title ?? "",
description: updated.description ?? "",
isPublic: Boolean(updated.isPublic),
});
setSavedMsg("Saved.");
setTimeout(() => setSavedMsg(null), 2000);
} catch (e: any) {
setError(e?.message ?? "Save failed");
} finally {
setBusy(false);
}
}
function isImage(file: File) {
return file.type.startsWith("image/");
}
async function fileToUpload(file: File, buildUuid: string) {
// 1) ask backend for presigned upload URL
const res = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`,
{
fileName: file.name,
contentType: file.type,
}
);
if (!res.ok) {
throw new Error(await res.text().catch(() => "Failed to get upload URL"));
}
const { uploadUrl, publicUrl } = await res.json();
// 2) upload directly to storage
const put = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error("Upload failed");
// 3) confirm + create photo record
const create = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`,
{
url: publicUrl,
caption: null,
sortOrder: 0,
}
);
if (!create.ok) {
throw new Error(await create.text().catch(() => "Failed to save photo"));
}
return (await create.json()) as BuildPhotoDto;
}
async function onDelete() {
if (!uuid) return;
const ok = window.confirm("Delete this build?\n\nThis cannot be undone.");
if (!ok) return;
setBusy(true);
setError(null);
try {
const res = await api.del(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Delete failed (${res.status})`);
}
router.replace("/vault");
router.refresh();
} catch (e: any) {
setError(e?.message ?? "Delete failed");
} finally {
setBusy(false);
}
}
return (
<main className="mx-auto max-w-3xl p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Edit Build</h1>
<div className="mt-1 text-xs text-zinc-500">
UUID: <span className="font-mono">{uuid}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link
href="/vault"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
>
Vault
</Link>
<button
className="rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
disabled={!uuid}
onClick={() =>
router.push(`/builder?load=${encodeURIComponent(uuid)}`)
}
title="Open this build in the builder to swap parts"
>
Open in Builder
</button>
</div>
</div>
{authLoading && <div className="text-sm text-zinc-400">Loading</div>}
{!authLoading && error && (
<pre className="mb-4 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
{error}
</pre>
)}
{!authLoading && build && (
<div className="space-y-4">
{/* meta */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-zinc-400">
Created: {fmt(build.createdAt)} Updated:{" "}
{fmt(build.updatedAt)}
</div>
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${
form.isPublic
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
>
{form.isPublic ? "Published" : "Draft"}
</span>
</div>
</div>
{/* parts */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold">Parts in this build</div>
<div className="text-xs text-zinc-400">
{build.items?.length ? `${build.items.length} items` : "—"}
</div>
</div>
{!build.items?.length ? (
<div className="text-sm opacity-70">No parts saved yet.</div>
) : (
<div className="overflow-hidden rounded-lg border border-white/10 bg-black/20">
{/* header */}
<div className="grid grid-cols-12 gap-3 border-b border-white/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
<div className="col-span-5">Product</div>
<div className="col-span-4">Component (Role)</div>
<div className="col-span-2">Brand</div>
<div className="col-span-1 text-right">Price</div>
</div>
{/* rows */}
{build.items
.slice()
.sort((a, b) => a.slot.localeCompare(b.slot))
.map((it, idx) => {
const price =
typeof it.bestPrice === "number"
? it.bestPrice
: it.bestPrice
? Number(it.bestPrice)
: null;
return (
<div
key={`${it.slot}-${it.productId}-${idx}`}
className="grid grid-cols-12 gap-2 border-b border-white/5 px-3 py-2 text-sm last:border-b-0"
>
{/* Product */}
<div className="col-span-5 min-w-0">
<div className="truncate text-zinc-100">
{it.productName ?? `Product #${it.productId}`}
</div>
<div className="text-xs text-zinc-500">
ID:{" "}
<span className="font-mono">{it.productId}</span>
</div>
</div>
{/* Component (Role) */}
<div className="col-span-4 truncate text-zinc-200">
{it.slot}
</div>
{/* Brand */}
<div className="col-span-2 truncate text-zinc-300">
{it.productBrand ?? "—"}
</div>
{/* Price */}
<div className="col-span-1 text-right font-semibold text-zinc-100">
{price != null && !Number.isNaN(price)
? `$${price.toFixed(2)}`
: "—"}
</div>
</div>
);
})}
</div>
)}
</div>
{/* form */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<label className="block text-xs font-medium text-zinc-300">
Title
</label>
<input
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
className="mt-2 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
placeholder="e.g. Suppressed MK2"
maxLength={120}
/>
<label className="mt-4 block text-xs font-medium text-zinc-300">
Notes / Description
</label>
<textarea
value={form.description}
onChange={(e) =>
setForm((f) => ({ ...f, description: e.target.value }))
}
className="mt-2 min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
maxLength={2000}
/>
<div className="mt-4 flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.isPublic}
onChange={(e) =>
setForm((f) => ({ ...f, isPublic: e.target.checked }))
}
/>
<span className="text-zinc-200">Publish to community</span>
</label>
<div className="flex items-center gap-2">
{savedMsg && (
<span className="text-xs text-emerald-300">{savedMsg}</span>
)}
<button
onClick={onSave}
disabled={busy || !dirty}
className="rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black hover:bg-zinc-200 disabled:opacity-50"
>
{busy ? "Working…" : dirty ? "Save" : "Saved"}
</button>
</div>
</div>
</div>
{/* Build Photos */}
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">Build Photos</div>
<div className="text-xs opacity-70">
Select photos, remove any you dont want, then upload.
</div>
</div>
<div className="flex items-center gap-2">
{/* Select (no upload) */}
<label
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
uploading ? "opacity-60 pointer-events-none" : ""
}`}
title={uploading ? "Uploading…" : "Select photos"}
>
Select Photos
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []).filter(
isImage
);
if (!files.length) return;
const local: LocalPreview[] = files.map((f) => ({
id:
typeof crypto !== "undefined" &&
"randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random()}`,
file: f,
url: URL.createObjectURL(f),
}));
setPreviews((prev) => [...local, ...prev]);
e.target.value = ""; // reset input so re-selecting same file works
}}
/>
</label>
{/* Upload (real upload) */}
<button
type="button"
disabled={uploading || previews.length === 0 || !build?.uuid}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
onClick={async () => {
if (!build?.uuid || previews.length === 0) return;
setError(null);
setUploading(true);
// snapshot so user can keep selecting more while current batch uploads
const batch = [...previews];
try {
for (const p of batch) {
// if user removed it before we got to it, skip
const stillThere = previews.some((x) => x.id === p.id);
if (!stillThere) continue;
const created = await fileToUpload(p.file, build.uuid);
setBuild((prev) =>
prev
? {
...prev,
photos: [created, ...(prev.photos ?? [])],
}
: prev
);
// remove preview after success
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}
} catch (err: any) {
setError(err?.message ?? "Photo upload failed");
} finally {
setUploading(false);
}
}}
>
{uploading
? "Uploading…"
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
</button>
</div>
</div>
{/* Gallery */}
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{/* Local previews (pending upload) */}
{previews.map((p) => (
<div
key={p.id}
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
{/* remove X */}
<button
type="button"
aria-label="Remove"
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
onClick={() => {
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}}
disabled={uploading}
title={
uploading
? "Wait for upload to finish"
: "Remove from upload list"
}
>
</button>
<img
src={p.url}
alt="Pending upload"
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80">
{uploading ? "Uploading…" : "Pending upload"}
</div>
</div>
))}
{/* Uploaded photos */}
{(build?.photos ?? []).map((p) => (
<div
key={p.uuid}
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
<img
src={p.url}
alt={p.caption ?? "Build photo"}
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80 line-clamp-2">
{p.caption ?? "—"}
</div>
</div>
))}
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
<div className="col-span-full text-xs opacity-70">
No photos yet. Select a few and upload when ready.
</div>
)}
</div>
</div>
{/* danger zone */}
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
<div className="text-sm font-semibold text-red-200">
Danger zone
</div>
<div className="mt-1 text-xs text-red-200/70">
Deleting a build is permanent.
</div>
<button
onClick={onDelete}
disabled={busy}
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-100 hover:bg-red-500/15 disabled:opacity-50"
>
Delete build
</button>
</div>
</div>
)}
</main>
);
}
+255
View File
@@ -0,0 +1,255 @@
// app/vault/page.tsx
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = {
id: string; // internal numeric id (not used by UI)
uuid: string; // public identifier
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// UUID validator (defensive)
const isUuid = (v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
v
);
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d;
return dt.toLocaleString();
}
export default function VaultPage() {
const router = useRouter();
const api = useApi();
// NOTE:
// - `loading` here is AuthContext hydration, not network.
// - `token` is the real gate for /me endpoints.
const { user, token, loading: authLoading } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const authed = !!user && !!token;
// Redirect if not logged in (after auth hydrates)
useEffect(() => {
if (!authLoading && !authed) {
router.replace("/login");
}
}, [authLoading, authed, router]);
// When auth identity changes, clear stale list to avoid “ghost builds”
useEffect(() => {
if (authLoading) return;
setBuilds([]);
setError(null);
}, [authLoading, user?.uuid, token]);
// Fetch user builds
useEffect(() => {
if (authLoading || !authed) return;
let cancelled = false;
(async () => {
setBusy(true);
setError(null);
try {
// IMPORTANT:
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
const res = await api.get("/api/v1/builds/me");
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const data = (await res.json()) as BuildDto[];
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds");
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [authLoading, authed, api]);
if (authLoading) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
}
// Already redirected
if (!authed) return null;
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Vault</h1>
<p className="text-sm opacity-70">
Your saved builds. Open one to keep tweaking parts, or add details
to submit it to the community.
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/builder"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
title="Create a new build in the builder"
>
New Build
</Link>
<Link href="/account" className="text-sm opacity-80 hover:underline" title={"Your account info"}>
Account
</Link>
</div>
</div>
{/* Error */}
{error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error}
</div>
)}
{/* Builds list */}
<div className="rounded-xl border border-white/10 bg-white/5">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="text-sm font-medium">My Builds</div>
<div className="text-xs opacity-70">
{busy ? "Refreshing…" : `${builds.length} total`}
</div>
</div>
{builds.length === 0 ? (
<div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save As
</div>
) : (
<ul className="divide-y divide-white/10">
{builds.map((b) => {
const valid = !!b?.uuid && isUuid(String(b.uuid));
const published = Boolean(b.isPublic);
const openHref = valid
? `/builder?load=${encodeURIComponent(b.uuid)}`
: "#";
const detailsHref = valid
? `/vault/${encodeURIComponent(b.uuid)}/edit`
: "#";
return (
<li
key={b.uuid}
className="p-4 flex items-start justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{b.title}</div>
{/* Status chip */}
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${
published
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
title={
published
? "This build is public (community-visible)."
: "Draft (only visible in your Vault)."
}
>
{published ? "Published" : "Draft"}
</span>
{!valid && (
<span
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
title="This build record has an invalid UUID."
>
Invalid UUID
</span>
)}
</div>
<div className="mt-1 text-xs opacity-70">
Updated: {fmt(b.updatedAt)} UUID:{" "}
<span className="font-mono">{b.uuid}</span>
</div>
{b.description && (
<div className="mt-2 text-sm opacity-80 line-clamp-2">
{b.description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href={openHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Load this build into the builder" : "Invalid build id"}
>
View in Builder
</Link>
<Link
href={detailsHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Edit details / publish later" : "Invalid build id"}
>
Edit Title
</Link>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import { Button, Field, Input } from "@/components/ui/form";
export default function AdminBetaInvitesPage() {
const [dryRun, setDryRun] = useState(true);
const [limit, setLimit] = useState("1");
const [tokenMinutes, setTokenMinutes] = useState("30");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
async function runInvites() {
setLoading(true);
setError(null);
setResult(null);
try {
const qs = new URLSearchParams({
dryRun: String(dryRun),
limit: String(limit || "0"),
tokenMinutes: String(tokenMinutes || "30"),
});
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
setResult(data);
} catch (e: any) {
setError(e?.message ?? "Failed");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender (uses your email templates + tracking).
</p>
</div>
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
Dry run (do everything except actually send)
</label>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Limit" htmlFor="limit">
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
</Field>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
/>
</Field>
</div>
<Button onClick={runInvites} disabled={loading}>
{loading ? "Running…" : "Send Beta Invites"}
</Button>
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{result && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
</div>
);
}
@@ -0,0 +1,585 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, X, Wand2, RefreshCw } from "lucide-react";
type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED";
type EnrichmentType = "CALIBER" | "CALIBER_GROUP";
type EnrichmentSource = "RULES" | "AI";
type QueueItem = {
id: number;
productId: number;
productName?: string;
productSlug?: string;
mainImageUrl?: string;
brandName?: string;
enrichmentType: EnrichmentType;
source: EnrichmentSource;
status: EnrichmentStatus;
attributes: Record<string, any>;
confidence: number;
rationale?: string;
createdAt: string;
// NEW: current value on products table (so we can prevent bad applies)
productCaliber?: string | null;
productCaliberGroup?: string | null;
};
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
function getAuthHeaders(): HeadersInit {
if (typeof window === "undefined") return {};
const token =
localStorage.getItem("token") ||
localStorage.getItem("jwt") ||
localStorage.getItem("accessToken");
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function apiFetch(path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
...(init?.headers ?? {}),
},
// If you're using cookie auth instead of bearer, flip this on:
// credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return res.json();
return res.text();
}
function hasProductCaliber(it: QueueItem) {
const c = it.productCaliber;
return typeof c === "string" && c.trim().length > 0;
}
function hasAlreadySet(it: QueueItem) {
if (it.enrichmentType === "CALIBER") {
return hasProductCaliber(it);
}
const g = it.productCaliberGroup;
return typeof g === "string" && g.trim().length > 0;
}
// ✅ ADD THIS: text for the “Already set:” pill
function alreadySetLabel(it: QueueItem) {
return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup;
}
export default function EnrichmentQueueClient() {
const [type, setType] = useState<EnrichmentType>("CALIBER");
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
const [limit, setLimit] = useState<number>(50);
const [items, setItems] = useState<QueueItem[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Bulk selection
const [selected, setSelected] = useState<Record<number, boolean>>({});
// “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately
// so the row swaps buttons without waiting for a reload.
const [localStatus, setLocalStatus] = useState<
Record<number, EnrichmentStatus>
>({});
const count = useMemo(() => items.length, [items]);
const selectedIds = useMemo(
() =>
Object.entries(selected)
.filter(([, v]) => v)
.map(([k]) => Number(k)),
[selected]
);
const allOnPageSelected = useMemo(() => {
if (items.length === 0) return false;
return items.every((it) => selected[it.id]);
}, [items, selected]);
function toggleAllOnPage() {
const next = { ...selected };
const nextValue = !allOnPageSelected;
for (const it of items) next[it.id] = nextValue;
setSelected(next);
}
function toggleOne(id: number) {
setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
}
function effectiveStatus(it: QueueItem): EnrichmentStatus {
return localStatus[it.id] ?? it.status;
}
async function load() {
setLoading(true);
setError(null);
try {
const data = (await apiFetch(
`/api/admin/enrichment/queue2?type=${encodeURIComponent(
type
)}&status=${encodeURIComponent(status)}&limit=${limit}`
)) as QueueItem[];
setItems(data ?? []);
// Keep selection only for items still visible
setSelected((prev) => {
const keep = new Set((data ?? []).map((x) => x.id));
const next: Record<number, boolean> = {};
for (const [k, v] of Object.entries(prev)) {
const id = Number(k);
if (keep.has(id)) next[id] = v;
}
return next;
});
// Reset local status for items not in view
setLocalStatus((prev) => {
const keep = new Set((data ?? []).map((x) => x.id));
const next: Record<number, EnrichmentStatus> = {};
for (const [k, v] of Object.entries(prev)) {
const id = Number(k);
if (keep.has(id)) next[id] = v;
}
return next;
});
} catch (e: any) {
setError(e?.message ?? "Failed to load queue");
} finally {
setLoading(false);
}
}
async function runRules() {
setLoading(true);
setError(null);
try {
await apiFetch(
`/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`,
{ method: "POST" }
);
setStatus("PENDING_REVIEW");
setTimeout(load, 50);
} catch (e: any) {
setError(e?.message ?? "Failed to run rules");
setLoading(false);
}
}
async function runAi() {
setLoading(true);
setError(null);
try {
await apiFetch(
`/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`,
{ method: "POST" }
);
setStatus("PENDING_REVIEW");
setTimeout(load, 50);
} catch (e: any) {
setError(e?.message ?? "Failed to run AI");
setLoading(false);
}
}
async function act(id: number, action: "approve" | "reject" | "apply") {
setBusyId(id);
setError(null);
// Optimistic UI for approve/reject so the row instantly changes
if (action === "approve")
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
if (action === "reject")
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
try {
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
method: "POST",
});
await load();
} catch (e: any) {
// rollback optimistic local status on error
setLocalStatus((p) => {
const next = { ...p };
delete next[id];
return next;
});
setError(e?.message ?? `Failed to ${action}`);
} finally {
setBusyId(null);
}
}
async function bulk(action: "approve" | "reject" | "apply") {
const ids = selectedIds;
if (ids.length === 0) return;
setLoading(true);
setError(null);
try {
// Apply should only run for items that are APPROVED (effective status) AND product caliber is blank
const byId = new Map(items.map((x) => [x.id, x]));
const filtered =
action === "apply"
? ids.filter((id) => {
const it = byId.get(id);
if (!it) return false;
return (
effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it)
);
})
: ids;
for (const id of filtered) {
// optimistic statuses for approve/reject in bulk
if (action === "approve")
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
if (action === "reject")
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
method: "POST",
});
}
setSelected({});
await load();
} catch (e: any) {
setError(e?.message ?? `Failed to bulk ${action}`);
setLoading(false);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, status, limit]);
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm text-zinc-400">
Showing <span className="text-zinc-200 font-medium">{count}</span>
</div>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={type}
onChange={(e) => setType(e.target.value as EnrichmentType)}
>
<option value="CALIBER">CALIBER</option>
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={status}
onChange={(e) => {
setSelected({});
setLocalStatus({});
setStatus(e.target.value as EnrichmentStatus);
}}
>
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
<option value="APPLIED">APPLIED</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={limit}
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={load}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</button>
<button
type="button"
onClick={runRules}
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
disabled={loading}
title="Run enrichment rules to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run Rules
</button>
<button
type="button"
onClick={runAi}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
disabled={loading}
title="Run AI enrichment to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run AI
</button>
</div>
</div>
{/* Bulk action bar */}
{selectedIds.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="text-sm text-zinc-300">
Selected{" "}
<span className="font-semibold">{selectedIds.length}</span>
<span className="ml-2 text-xs text-zinc-500">
(Apply only works on APPROVED + product caliber blank)
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => bulk("approve")}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Approve Selected
</button>
<button
type="button"
onClick={() => bulk("reject")}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Reject Selected
</button>
<button
type="button"
onClick={() => bulk("apply")}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
disabled={loading}
title="Applies only APPROVED rows where product caliber is blank"
>
Apply Selected
</button>
<button
type="button"
onClick={() => setSelected({})}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Clear
</button>
</div>
</div>
) : null}
</div>
{error ? (
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
{error}
</div>
) : null}
{/* Table */}
<div className="overflow-hidden rounded-md border border-zinc-800">
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={toggleAllOnPage}
className="h-4 w-4 accent-amber-400"
aria-label="Select all"
/>
</div>
<div>Product</div>
<div className="text-right">Confidence</div>
<div className="text-right">Suggested</div>
<div className="text-right">Actions</div>
</div>
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
{items.length === 0 ? (
<div className="p-6 text-sm text-zinc-400">No items found.</div>
) : (
items.map((it) => {
const suggested =
it.enrichmentType === "CALIBER"
? it.attributes?.caliber
: it.attributes?.caliberGroup; const isBusy = busyId === it.id;
const st = effectiveStatus(it);
const alreadySet = hasAlreadySet(it);
const alreadySetValue = alreadySetLabel(it);
return (
<div
key={it.id}
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={!!selected[it.id]}
onChange={() => toggleOne(it.id)}
className="h-4 w-4 accent-amber-400"
aria-label={`Select enrichment ${it.id}`}
/>
</div>
<div className="min-w-0">
<div className="flex items-start gap-3">
{it.mainImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.mainImageUrl}
alt=""
className="h-10 w-10 rounded border border-zinc-800 object-cover"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
)}
<div className="min-w-0">
<a
href={`/admin/products/${it.productId}`}
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
>
{it.productName ?? `Product #${it.productId}`}
</a>
<div className="mt-0.5 text-xs text-zinc-500">
{it.brandName ? `${it.brandName}` : ""}
{it.source} {st}
{alreadySet ? (
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
Already set: {alreadySetValue}
</span>
) : null}
</div>
{it.rationale ? (
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
{it.rationale}
</div>
) : null}
</div>
</div>
</div>
<div className="text-right text-sm font-semibold text-zinc-200">
{(it.confidence ?? 0).toFixed(2)}
</div>
<div className="text-right text-sm text-amber-300">
{suggested ?? "—"}
</div>
<div className="flex justify-end gap-2">
{st === "PENDING_REVIEW" && (
<>
<button
type="button"
onClick={() => act(it.id, "approve")}
disabled={isBusy || alreadySet}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
title={
alreadySet
? "Product caliber already set"
: "Approve"
}
aria-label="Approve"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => act(it.id, "reject")}
disabled={isBusy}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
title="Reject"
aria-label="Reject"
>
<X className="h-4 w-4" />
</button>
</>
)}
{st === "APPROVED" && (
<button
type="button"
onClick={() => act(it.id, "apply")}
disabled={isBusy || alreadySet}
className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
title={
alreadySet
? `Already set on product: ${alreadySetValue}`
: "Apply (writes to product if blank)"
}
>
Apply
</button>
)}
{st === "APPLIED" && (
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
Applied
</span>
)}
{st === "REJECTED" && (
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
Rejected
</span>
)}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import EnrichmentQueueClient from "./EnrichmentQueueClient";
export const dynamic = "force-dynamic";
export default function AdminEnrichmentPage() {
return (
<div className="mx-auto w-full max-w-6xl px-6 py-8">
<div className="mb-6">
<h1 className="text-xl font-semibold text-zinc-50">Enrichment Queue</h1>
<p className="mt-1 text-sm text-zinc-400">
Review AI/rules suggestions, approve/reject, then apply to products.
</p>
</div>
<EnrichmentQueueClient />
</div>
);
}
+54 -59
View File
@@ -1,7 +1,10 @@
"use client";
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 { useAuth } from "@/context/AuthContext";
import {
LayoutDashboard,
Download,
@@ -11,81 +14,72 @@ import {
Users,
Settings,
LucideMail,
Wand2,
} from "lucide-react";
const navItems = [
{
label: "Dashboard",
href: "/admin",
icon: <LayoutDashboard className="h-4 w-4" />,
},
{
label: "Imports",
href: "/admin/import-status",
icon: <Download className="h-4 w-4" />,
},
{
label: "Mappings",
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" />,
},
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
{ label: "Mappings", 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: "Enrichment", href: "/admin/enrichment", icon: <Wand2 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 an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
{ label: "Beta Invites", href: "/admin/beta-invites", 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({
children,
}: {
children: React.ReactNode;
}) {
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 (
<div className="flex min-h-screen bg-black text-zinc-50">
<AdminLeftNavigation
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)}
items={navItems}
/>
{/* Main column */}
@@ -100,6 +94,7 @@ export default function AdminLayout({
Battl Builders Control Panel
</p>
</div>
<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">
Internal v0.1
+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";
const BREVO_API_KEY = process.env.BREVO_API_KEY;
const BREVO_LIST_ID = process.env.BREVO_LIST_ID; // optional
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
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 {
const { email } = await req.json();
const body = await req.json();
if (!email || typeof email !== "string") {
return NextResponse.json(
{ error: "Valid email is required" },
{ status: 400 }
);
}
// Brevo contacts API
const res = await fetch("https://api.brevo.com/v3/contacts", {
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"api-key": BREVO_API_KEY,
},
body: JSON.stringify({
email,
updateEnabled: true, // update if already exists
...(BREVO_LIST_ID
? { listIds: [Number(BREVO_LIST_ID)] }
: {}),
attributes: {
SOURCE: "Battl Builders Beta",
},
}),
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
// Always return ok=true (matches your server behavior)
if (!res.ok) {
const text = await res.text();
console.error("Brevo error:", res.status, text);
return NextResponse.json(
{ error: "Failed to subscribe" },
{ status: 500 }
);
// Optional: log server response for debugging
const text = await res.text().catch(() => "");
console.error("beta-signup proxy failed:", res.status, text);
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "Something went wrong" },
{ status: 500 }
);
} catch (e) {
console.error("beta-signup proxy error:", e);
return NextResponse.json({ ok: true }); // keep “no enumeration”
}
}
+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>
);
}
+219 -104
View File
@@ -1,103 +1,182 @@
"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";
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 = {
id: string;
id: string; // we use uuid here (public identifier)
title: string;
slug: string;
creator: string;
slug: string; // can be uuid for now; keep property so UI does not change
creator: string; // placeholder until user profiles are real
caliber: Caliber;
buildClass: BuildClass;
price: number;
price: number; // optional server-side later; for now allow 0 fallback
votes: number;
tags: string[];
coverImageUrl?: string | null;
};
const DUMMY_BUILDS: BuildCard[] = [
{
id: "1",
title: "Duty-Grade 12.5\" AR-15",
slug: "duty-12-5-ar15",
creator: "quietpro_01",
caliber: "5.56",
buildClass: "Rifle",
price: 2450,
votes: 128,
tags: ["Duty", "NV-Ready", "LPVO"],
},
{
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"],
},
];
type BuildFeedCardDto = {
uuid: string;
title?: string | null;
slug?: string | null; // optional (we can generate from title later)
creator?: string | null; // optional
caliber?: string | null;
buildClass?: BuildClass | null;
price?: number | null;
votes?: number | null;
tags?: string[] | null;
coverImageUrl?: string | null;
};
const CALIBER_FILTERS: (Caliber | "all")[] = [
"all",
"5.56",
"7.62",
"9mm",
".300 BLK",
"12ga",
];
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000 },
{ id: "1to2k", label: "$1k$2k", min: 1000, max: 2000 },
{ id: "2to3k", label: "$2k$3k", min: 2000, max: 3000 },
{ id: "3kplus", label: "$3k+", min: 3000, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ 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() {
// --- 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 [classFilter, setClassFilter] = useState<BuildClass | "all">("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(
(p) => p.id === priceFilterId,
) ?? PRICE_FILTERS[0];
// Local vote state for optimistic UI
const [votes, setVotes] = useState<Record<string, number>>({});
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(() => {
return DUMMY_BUILDS.filter((build) => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
@@ -110,14 +189,16 @@ export default function BuildsPage() {
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) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
};
}, []);
return (
<main className="min-h-screen bg-black text-zinc-50">
@@ -136,11 +217,12 @@ export default function BuildsPage() {
Community Builds
</h1>
<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
good ideas without shame. This is placeholder content for now
real accounts, comments, and saved builds will wire in later.
Browse community builds, vote them up or down, and steal good
ideas without shame. This feed is backed by public builds
(isPublic=true).
</p>
</div>
<div className="flex gap-2 md:gap-3">
<Link
href="/builder"
@@ -148,15 +230,35 @@ export default function BuildsPage() {
>
Open Builder
</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"
>
+ Submit Build (Coming Soon)
</button>
+ Submit Build
</Link>
</div>
</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 */}
<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">
@@ -165,21 +267,19 @@ export default function BuildsPage() {
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, platform class, and ballpark price range.
All logic is client-side placeholder until the real feed is wired
into the backend.
Filter by caliber, class, and price range. (Client-side for
MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">
{DUMMY_BUILDS.length}
</span>{" "}
demo builds
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
@@ -219,9 +319,7 @@ export default function BuildsPage() {
<button
key={cls}
type="button"
onClick={() =>
setClassFilter(cls === "all" ? "all" : cls)
}
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
@@ -262,7 +360,10 @@ export default function BuildsPage() {
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => (
{filteredBuilds.map((build) => {
const dollars = Math.floor((build.price ?? 0) / 100);
return (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
@@ -273,6 +374,8 @@ export default function BuildsPage() {
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>
@@ -283,18 +386,23 @@ export default function BuildsPage() {
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>
{/* Placeholder thumbnail */}
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://placehold.co/320x200/png?text=Build+Photo"
alt={`${build.title} placeholder`}
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
@@ -320,16 +428,23 @@ export default function BuildsPage() {
<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.
· Build detail page will show the parts list +
comments.
</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()}
{build.price > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
@@ -343,19 +458,19 @@ export default function BuildsPage() {
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments, save, and share coming soon.
Comments + save coming soon.
</span>
</div>
</div>
</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">
No builds match those filters yet. Once the real feed is wired
up, this will update live as the community posts rifles, pistols,
and NFA builds.
No builds match those filters yet.
</div>
)}
</div>
+13 -2
View File
@@ -1,6 +1,8 @@
// app/layout.tsx
import "./globals.css";
import type { ReactNode } from "react";
import Script from "next/script";
import { AuthProvider } from "@/context/AuthContext";
import { Banner } from "@/components/Banner";
@@ -10,6 +12,12 @@ export const metadata = {
template: "%s — Battl Builders",
},
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 = `
@@ -25,13 +33,16 @@ const themeScript = `
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" suppressHydrationWarning className="dark">
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="bg-white text-zinc-950 dark:bg-black dark:text-zinc-50">
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
{" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
strategy="lazyOnload"
/>
<AuthProvider>
<Banner />
{children}
+106 -5
View File
@@ -1,12 +1,15 @@
// app/login/page.tsx
"use client";
import { FormEvent, useState } from "react";
import { FormEvent, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
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() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -14,9 +17,18 @@ export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
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) {
e.preventDefault();
@@ -26,7 +38,52 @@ export default function LoginPage() {
await login({ email, password });
router.push(next);
} 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);
}
}
@@ -34,12 +91,13 @@ export default function LoginPage() {
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
<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>
<p className="mt-2 text-sm text-zinc-400">
Use your beta credentials to get back to your saved builds.
</p>
{/* Password Login */}
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
@@ -74,7 +132,50 @@ export default function LoginPage() {
</Button>
</form>
<p className="mt-4 text-xs text-zinc-500">
{/* Divider */}
<div className="mt-8 flex items-center gap-3">
<div className="h-px flex-1 bg-white/10" />
<span className="text-xs text-zinc-500">or</span>
<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>
</div>
<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
+38 -12
View File
@@ -13,6 +13,7 @@ export default function HomePage() {
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!email) {
setMessage("Drop an email in first, operator.");
setStatus("error");
@@ -30,11 +31,12 @@ export default function HomePage() {
});
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");
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("");
setUseCase("");
} catch (err) {
@@ -98,7 +100,7 @@ export default function HomePage() {
<div className="flex items-center gap-3">
<div className="flex items-center">
<Image
src="/battl/battl-logo-mark-2.svg"
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo"
width={260} // adjust to taste
height={48} // adjust to taste
@@ -160,7 +162,8 @@ export default function HomePage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
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
/>
</div>
@@ -178,17 +181,22 @@ export default function HomePage() {
value={useCase}
onChange={(e) => setUseCase(e.target.value)}
rows={3}
disabled={status === "loading" || status === "success"}
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>
<button
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"
>
{status === "loading" ? "Enlisting…" : "Get Beta Access"}
{status === "loading"
? "Enlisting…"
: status === "success"
? "On the List ✅"
: "Get Beta Access"}
</button>
{message && (
@@ -205,10 +213,25 @@ export default function HomePage() {
</p>
)}
<p className="pt-1 text-[10px] leading-relaxed text-zinc-500">
You can one-click unsubscribe any time with one-click. No data
games, no surprise newsletters.
</p>
{status === "success" && (
<button
type="button"
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>
</div>
</section>
@@ -216,7 +239,10 @@ export default function HomePage() {
{/* Footer strip */}
<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">
<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">
v0.1 Import engine online Builder UI in progress
</span>
-173
View File
@@ -1,173 +0,0 @@
// app/vault/page.tsx
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
type BuildDto = {
id: string;
uuid: string; // UUID string
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d;
return dt.toLocaleString();
}
export default function VaultPage() {
const router = useRouter();
const { user, loading, getAuthHeaders } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const authed = !!user;
useEffect(() => {
if (!loading && !authed) {
router.replace("/login");
}
}, [loading, authed, router]);
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
useEffect(() => {
if (loading || !authed) return;
let cancelled = false;
(async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, {
method: "GET",
headers,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const data = (await res.json()) as BuildDto[];
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds");
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [loading, authed, headers]);
if (loading) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
}
// If not authed, we already redirected; render nothing.
if (!authed) return null;
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Vault</h1>
<p className="text-sm opacity-70">
Your saved builds. Load one into the builder or publish it later.
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/builder"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
>
New Build
</Link>
<Link
href="/account"
className="text-sm opacity-80 hover:underline"
>
Account
</Link>
</div>
</div>
{error ? (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error}
</div>
) : null}
<div className="rounded-xl border border-white/10 bg-white/5">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="text-sm font-medium">My Builds</div>
<div className="text-xs opacity-70">
{busy ? "Refreshing…" : `${builds.length} total`}
</div>
</div>
{builds.length === 0 ? (
<div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save to Vault.
</div>
) : (
<ul className="divide-y divide-white/10">
{builds.map((b) => (
<li key={b.uuid} className="p-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="font-medium truncate">{b.title}</div>
<div className="mt-1 text-xs opacity-70">
Updated: {fmt(b.updatedAt)} UUID:{" "}
<span className="font-mono">{b.uuid}</span>
</div>
{b.description ? (
<div className="mt-2 text-sm opacity-80 line-clamp-2">
{b.description}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Placeholder: wire this to "load into builder" next */}
<Link
href={`/builder?load=${b.uuid}`}
// future href to support platform
// href={`/builder?platform=${encodeURIComponent(platform)}&load=${b.uuid}`}
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
>
Open
</Link>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+30 -58
View File
@@ -1,6 +1,7 @@
"use client";
import type React from "react";
import Link from "next/link";
import {
LayoutDashboard,
Download,
@@ -10,70 +11,41 @@ import {
Users,
Settings,
LucideMail,
Wand2,
} from "lucide-react";
import Link from "next/link";
export type AdminNavItem = {
label: string;
href: string;
icon: React.ReactNode;
};
type AdminLeftNavigationProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
items?: AdminNavItem[]; // ✅ NEW
};
const navItems = [
{
label: "Dashboard",
href: "/admin",
icon: <LayoutDashboard className="h-4 w-4" />,
},
{
label: "Imports",
href: "/admin/import-status",
icon: <Download className="h-4 w-4" />,
},
{
label: "Mappings",
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
{
label: "Manage Emails",
href: "/admin/email/manage",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
const defaultNavItems: AdminNavItem[] = [
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
{ label: "Mappings", 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: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
{ label: "Manage Emails", href: "/admin/email/manage", icon: <LucideMail className="h-4 w-4" /> },
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" />,
},
];
export default function AdminLeftNavigation({
collapsed,
onToggleCollapsed,
items = defaultNavItems, // ✅ NEW default
}: AdminLeftNavigationProps) {
return (
<aside
@@ -98,7 +70,9 @@ export default function AdminLeftNavigation({
{!collapsed && (
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
<Link href={"/"} title={"Back to Battl Dashboard"} >Battl Builders</Link>
<Link href="/" title="Back to Battl Dashboard">
Battl Builders
</Link>
</p>
<p className="text-[10px] text-zinc-600">Admin Command</p>
</div>
@@ -106,8 +80,8 @@ export default function AdminLeftNavigation({
</div>
<nav className="flex-1 space-y-1 text-sm">
{navItems.map((item) => (
<a
{items.map((item) => (
<Link
key={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"
@@ -117,15 +91,13 @@ export default function AdminLeftNavigation({
{!collapsed && <span>{item.label}</span>}
</div>
{!collapsed && <span className="text-[10px] text-zinc-600"></span>}
</a>
</Link>
))}
</nav>
{!collapsed && (
<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">
Status
</p>
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
<p className="mt-1">
Import engine: <span className="text-emerald-400">online</span>
</p>
+1 -1
View File
@@ -11,7 +11,7 @@ export function Banner() {
Early Access Beta
</span>
<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.
</span>
</div>
+9 -2
View File
@@ -57,6 +57,8 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
const renderDropdown = (label: string, items: Category[]) => {
if (!items.length) return null;
const platform = searchParams.get("platform");
return (
<div className="relative group">
{/* Dropdown trigger */}
@@ -80,7 +82,10 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
return (
<li key={cat.id}>
<Link
href={`${baseHref}/${cat.id}`}
href={{
pathname: `${baseHref}/${cat.id}`,
query: platform ? { platform } : {},
}}
className={`block w-full px-3 py-1.5 transition-colors ${
isActive
? "bg-neutral-800 text-white"
@@ -162,7 +167,9 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
{/* "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="uppercase tracking-wide text-[10px]">
Viewing
</span>
<span className="font-medium text-neutral-100">
{CATEGORIES.find((c) => c.id === currentCategory)?.name ??
+1 -1
View File
@@ -1,6 +1,6 @@
"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 {
data: PriceHistoryPoint[];
+1 -1
View File
@@ -1,6 +1,6 @@
"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 {
retailers: RetailerOffer[];
+4 -1
View File
@@ -24,11 +24,14 @@ export default function ThemeToggle() {
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"
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"
+6 -2
View File
@@ -11,9 +11,11 @@ import ThemeToggle from "@/components/ThemeToggle";
function NavLink({
href,
children,
title
}: {
href: string;
children: React.ReactNode;
title?: string;
}) {
const pathname = usePathname();
const active = pathname === href;
@@ -21,6 +23,7 @@ function NavLink({
return (
<Link
href={href}
title={title}
className={[
"text-xs font-medium transition-colors",
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
@@ -44,7 +47,7 @@ export function TopNav() {
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
>
<Image
src="/battl/battl-logo-mark-2.svg"
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo"
width={260}
height={48}
@@ -60,7 +63,7 @@ export function TopNav() {
<span className="text-xs text-zinc-500">Checking session</span>
) : isAuthenticated ? (
<>
<NavLink href="/vault">Vault</NavLink>
<NavLink href="/vault" title={"Your builds"}>Vault</NavLink>
{/* Email/displayName links to Account */}
<Link
@@ -96,3 +99,4 @@ export function TopNav() {
</header>
);
}
export default TopNav;
+99 -117
View File
@@ -1,8 +1,3 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
/**
* PartsBrowseClient
* -----------------------------------------------------------------------------
@@ -13,8 +8,11 @@ import { usePathname } from "next/navigation";
* - layout + list/card views
*/
"use client";
import Link from "next/link";
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 SortBar from "@/components/parts/SortBar";
@@ -49,7 +47,7 @@ type UiPart = {
brand: string;
platform: string;
partRole: string;
price: number; // normalized
price: number;
imageUrl?: string;
buyUrl?: string;
inStock?: boolean;
@@ -57,6 +55,7 @@ type UiPart = {
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const PAGE_SIZE = 24;
function normalizeId(id: string | number) {
@@ -92,8 +91,13 @@ export default function PartsBrowseClient(props: {
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 partRole = props.partRole;
const [viewMode, setViewMode] = useState<ViewMode>("list");
@@ -103,7 +107,7 @@ export default function PartsBrowseClient(props: {
const [brandFilter, setBrandFilter] = useState<string[]>([]);
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;
@@ -114,6 +118,9 @@ export default function PartsBrowseClient(props: {
const [inStockOnly, setInStockOnly] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
// ----------------------------
// Fetch parts
// ----------------------------
useEffect(() => {
if (!partRole) return;
@@ -128,14 +135,17 @@ export default function PartsBrowseClient(props: {
search.set("platform", effectivePlatform);
search.append("partRoles", partRole);
const url = `${API_BASE_URL}/api/v1/products?${search.toString()}`;
const res = await fetch(url, { signal: controller.signal });
const res = await fetch(
`${API_BASE_URL}/api/v1/products?${search.toString()}`,
{ signal: controller.signal }
);
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: UiPart[] = data.map((p) => ({
setParts(
data.map((p) => ({
id: normalizeId(p.id),
name: p.name,
brand: p.brand,
@@ -145,9 +155,8 @@ export default function PartsBrowseClient(props: {
imageUrl: p.mainImageUrl ?? undefined,
buyUrl: p.buyUrl ?? undefined,
inStock: p.inStock ?? true,
}));
setParts(normalized);
}))
);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
@@ -160,6 +169,7 @@ export default function PartsBrowseClient(props: {
return () => controller.abort();
}, [partRole, effectivePlatform]);
// Reset pagination on filters
useEffect(() => {
setCurrentPage(1);
}, [
@@ -172,11 +182,14 @@ export default function PartsBrowseClient(props: {
inStockOnly,
]);
// Reset scroll on route change
useEffect(() => {
// Reset scroll position whenever we land on or change parts routes
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
window.scrollTo({ top: 0, behavior: "auto" });
}, [pathname]);
// ----------------------------
// Derived values
// ----------------------------
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
@@ -186,34 +199,30 @@ export default function PartsBrowseClient(props: {
);
const priceBounds = useMemo(() => {
if (parts.length === 0)
if (!parts.length)
return { min: null as number | null, max: null as number | null };
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, max };
return {
min: Math.min(...parts.map((p) => p.price)),
max: Math.max(...parts.map((p) => p.price)),
};
}, [parts]);
// Keep priceRange clamped to bounds when bounds change
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) => {
const min = prev.min ?? priceBounds.min!;
const max = prev.max ?? priceBounds.max!;
const nextMin =
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 {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
min: Math.min(nextMin, nextMax),
max: Math.max(nextMin, nextMax),
};
});
}, [priceBounds.min, priceBounds.max]);
@@ -221,30 +230,22 @@ export default function PartsBrowseClient(props: {
const filteredParts = useMemo(() => {
let result = [...parts];
const effectiveMin = priceRange.min ?? priceBounds.min;
const effectiveMax = priceRange.max ?? priceBounds.max;
if (effectiveMin != null && effectiveMax != null) {
result = result.filter(
(p) => p.price >= effectiveMin && p.price <= effectiveMax
);
}
if (priceRange.min != null)
result = result.filter((p) => p.price >= priceRange.min!);
if (priceRange.max != null)
result = result.filter((p) => p.price <= priceRange.max!);
if (brandFilter.length > 0) {
if (brandFilter.length)
result = result.filter((p) => brandFilter.includes(p.brand));
}
if (inStockOnly) {
result = result.filter((p) => p.inStock ?? true);
}
if (inStockOnly) result = result.filter((p) => p.inStock ?? true);
const q = searchQuery.trim().toLowerCase();
if (q) {
result = result.filter((p) => {
const name = (p.name ?? "").toLowerCase();
const brand = (p.brand ?? "").toLowerCase();
return name.includes(q) || brand.includes(q);
});
}
const q = searchQuery.toLowerCase().trim();
if (q)
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) || p.brand.toLowerCase().includes(q)
);
switch (sortBy) {
case "price-asc":
@@ -262,47 +263,25 @@ export default function PartsBrowseClient(props: {
}
return result;
}, [
parts,
brandFilter,
sortBy,
searchQuery,
priceRange,
priceBounds.min,
priceBounds.max,
inStockOnly,
]);
}, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
const totalPages = useMemo(
() =>
filteredParts.length === 0
? 1
: Math.ceil(filteredParts.length / PAGE_SIZE),
[filteredParts.length]
const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE));
const paginatedParts = filteredParts.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
const paginatedParts = useMemo(() => {
if (filteredParts.length === 0) return [];
const startIndex = (currentPage - 1) * PAGE_SIZE;
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 visibleRange = {
start: filteredParts.length ? (currentPage - 1) * PAGE_SIZE + 1 : 0,
end: Math.min(currentPage * PAGE_SIZE, filteredParts.length),
};
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 normalizedRole = normalizePartRole(partRole);
const categoryId: CategoryId | null =
@@ -327,14 +306,13 @@ export default function PartsBrowseClient(props: {
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 className="mb-6">
<div className="flex items-start justify-between gap-4">
<header className="mb-6 flex 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">
<h1 className="mt-1 text-2xl md:text-3xl font-semibold">
{headingTitle}{" "}
<span className="text-amber-300">{effectivePlatform}</span>
</h1>
@@ -343,29 +321,39 @@ export default function PartsBrowseClient(props: {
{headingSubtitle}
</p>
<div className="mt-3">
{!isBuilderMode && (
<div className="mt-4 flex flex-wrap items-center gap-3">
<PlatformSwitcher
currentPlatform={effectivePlatform}
partRole={partRole}
preserveQuery
mode="browse"
/>
<Link
href={`/builder?platform=${encodeURIComponent(
effectivePlatform
)}`}
className="inline-flex items-center rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
>
Start New Build
</Link>
</div>
)}
</div>
{!loading && !error && (
{isBuilderMode && (
<div className="flex items-center gap-2">
<Link
href={{
pathname: "/builder",
query: effectivePlatform
? { platform: effectivePlatform }
: {},
}}
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"
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
@@ -394,13 +382,12 @@ export default function PartsBrowseClient(props: {
)}
</div>
)}
</div>
</header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<div className="flex flex-col gap-4 md:flex-row">
{!loading && !error && parts.length > 0 && (
<aside className="w-full md:w-64 shrink-0">
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4">
<div className="flex flex-col md:flex-row gap-4">
{parts.length > 0 && (
<aside className="w-full md:w-64">
<Filters
partRoleLabel={partRole}
availableBrands={availableBrands}
@@ -416,7 +403,7 @@ export default function PartsBrowseClient(props: {
)}
<div className="flex-1">
{!loading && !error && parts.length > 0 && (
{!loading && !error && (
<SortBar
visibleRange={visibleRange}
totalCount={filteredParts.length}
@@ -432,9 +419,7 @@ export default function PartsBrowseClient(props: {
Loading
</p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
<p className="py-8 text-center text-sm text-red-400">{error}</p>
) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">
No parts found for this role yet.
@@ -451,10 +436,7 @@ export default function PartsBrowseClient(props: {
/>
)}
{!loading &&
!error &&
filteredParts.length > 0 &&
totalPages > 1 && (
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
+28 -19
View File
@@ -1,11 +1,13 @@
"use client";
import Link from "next/link";
import { Plus } from "lucide-react";
type UiPart = {
id: string;
name: string;
brand: string;
caliber?: string;
platform: string;
partRole: string;
price: number;
@@ -36,7 +38,8 @@ export default function PartsGrid(props: {
<div className="mb-3 flex items-center justify-between gap-2">
<div className="min-w-0">
<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>
@@ -89,8 +92,9 @@ export default function PartsGrid(props: {
return (
<>
{/* Header (DESKTOP ONLY) */}
<div className="hidden md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_260px] md:items-center md:gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500"> <span className="min-w-0">Part</span>
<span className="min-w-0">Brand</span>
<div className="hidden md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
<span className="min-w-0">Part</span>
<span className="text-right">Caliber</span>
<span className="text-right">Price</span>
<span className="text-right">Actions</span>
</div>
@@ -102,17 +106,27 @@ export default function PartsGrid(props: {
key={part.id}
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_260px] md:items-center md:gap-4">
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4">
{/* Part */}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-50">
<Link
href={buildDetailHref(part)}
className="block text-sm font-semibold text-zinc-50 leading-snug line-clamp-2 break-words hover:text-amber-300 transition-colors"
title={part.name}
>
{part.name}
</div>
</div>
{/* Brand */}
<div className="min-w-0 text-xs text-zinc-400 md:text-sm">
</Link>
{/* Dont think we need Brand in the grid */}
{/* <div className="mt-0.5 text-xs text-zinc-500 line-clamp-1">
{part.brand}
{part.caliber ? (
<span className="text-zinc-600"> • {part.caliber}</span>
) : null}
</div> */}
</div>
{/* Caliber (desktop) */}
<div className="hidden md:block text-sm text-zinc-300 text-right">
{part.caliber ?? "—"}
</div>
{/* Price */}
@@ -122,20 +136,15 @@ export default function PartsGrid(props: {
{/* Actions */}
<div className="flex justify-end gap-2">
<Link
href={buildDetailHref(part)}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
{onAddToBuild ? (
<button
type="button"
onClick={() => onAddToBuild(part)}
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
className="inline-flex items-center justify-center h-8 w-8 rounded-md bg-amber-400 text-black hover:bg-amber-300 transition-colors"
aria-label="Add to Build"
title="Add to Build"
>
{addLabel}
<Plus className="h-4 w-4" />
</button>
) : part.buyUrl ? (
<a
+57 -42
View File
@@ -1,60 +1,75 @@
"use client";
import { useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
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: {
currentPlatform: 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;
/**
* 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 searchParams = useSearchParams();
const queryString = useMemo(() => {
if (!preserveQuery) return "";
const q = searchParams?.toString();
return q ? `?${q}` : "";
}, [searchParams, preserveQuery]);
const inferredMode: "browse" | "detail" = pathname.startsWith("/parts/p/")
? "detail"
: "browse";
function go(platform: string) {
// We always navigate to canonical to keep it simple & consistent
router.push(`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}${queryString}`);
const mode = props.mode ?? inferredMode;
const buildHref = (nextPlatform: Platform) => {
if (mode === "detail") {
return `/parts/p/${encodeURIComponent(nextPlatform)}/${encodeURIComponent(
props.partRole
)}`;
}
return (
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-[0.14em] text-zinc-500">Platform</span>
<select
value={currentPlatform}
onChange={(e) => go(e.target.value)}
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"
>
<option value="AR-15">AR-15</option>
<option value="AR-10">AR-10</option>
<option value="AR-9">AR-9</option>
<option value="AK-47">AK-47</option>
</select>
// browse mode => keep on /parts/[role] and just change the query param
const qp = props.preserveQuery
? new URLSearchParams(searchParams.toString())
: new URLSearchParams();
<span className="ml-2 text-[0.7rem] text-zinc-500">
{pathname}
</span>
qp.set("platform", nextPlatform);
return {
pathname: `/parts/${encodeURIComponent(props.partRole)}`,
query: Object.fromEntries(qp.entries()),
} as const;
};
return (
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950/60 p-1">
{PLATFORMS.map((p) => {
const active = p === props.currentPlatform;
return (
<Link
key={p}
href={buildHref(p)}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
active
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/60"
}`}
>
{p}
</Link>
);
})}
</div>
);
}
+3 -2
View File
@@ -122,11 +122,12 @@ export default function ProductDetailPageClient(props: {
</h1>
</div>
<PlatformSwitcher currentPlatform={platform} partRole={partRole} />
{/* temp disabling while I rework the routing. */}
{/* <PlatformSwitcher currentPlatform={platform} partRole={partRole} /> */}
</div>
<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
</Link>
<span></span>
+81 -34
View File
@@ -13,6 +13,7 @@ const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type AuthUser = {
uuid: string;
email: string;
displayName?: string | null;
role: string;
@@ -30,6 +31,12 @@ type AuthContextValue = {
}) => Promise<void>;
logout: () => void;
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);
@@ -42,30 +49,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true);
// Hydrate from localStorage on first load
useEffect(() => {
if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken) {
setToken(storedToken);
}
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch {
// bad JSON? wipe it
window.localStorage.removeItem(USER_KEY);
}
}
setLoading(false);
}, []);
const persistAuth = useCallback((nextToken: string | null, nextUser: AuthUser) => {
/**
* Persist auth to localStorage
*/
const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return;
if (nextToken) {
@@ -79,8 +67,67 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} else {
window.localStorage.removeItem(USER_KEY);
}
},
[]
);
/**
* ✅ Hydrate from localStorage ONCE
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
*/
useEffect(() => {
if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken && storedUser) {
try {
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 {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
}
}
setLoading(false);
}, []);
/**
* Used by login/register/magic-link
*/
const setSession = useCallback(
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
// ✅ Make middleware / server aware of the session
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: nextToken,
role: nextUser.role,
}),
}).catch(() => {});
},
[persistAuth]
);
const login = useCallback(
async ({ email, password }: { email: string; password: string }) => {
setLoading(true);
@@ -98,24 +145,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await res.json();
// Adjust these to match your backend response shape
const nextToken: string = data.token ?? data.accessToken;
const nextUser: AuthUser =
data.user ??
({
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
} as AuthUser);
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
setSession(nextToken, nextUser as NonNullable<AuthUser>);
} finally {
setLoading(false);
}
},
[persistAuth]
[setSession]
);
const register = useCallback(
@@ -147,25 +192,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser =
data.user ??
({
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null,
role: data.role ?? "USER",
} as AuthUser);
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
setSession(nextToken, nextUser as NonNullable<AuthUser>);
} finally {
setLoading(false);
}
},
[persistAuth]
[setSession]
);
const logout = useCallback(() => {
setToken(null);
setUser(null);
persistAuth(null, null);
// Clear server session cookies
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
}, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => {
@@ -180,12 +227,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register,
logout,
getAuthHeaders,
setSession,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 🔑 This is what your useApi hook imports
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
+48 -11
View File
@@ -1,26 +1,63 @@
"use client";
import { useMemo } from "react";
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() {
const { token } = useAuth();
async function get(path: string) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
return useMemo(() => {
function authHeaders(extra?: HeadersInit): HeadersInit {
const base: Record<string, string> = {};
if (token) base.Authorization = `Bearer ${token}`;
return { ...base, ...(extra as any) };
}
async function get(path: string, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
...init,
headers: authHeaders(init?.headers),
});
}
async function post(path: string, body: any) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
async function post(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "POST",
headers: {
...init,
headers: authHeaders({
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
...(init?.headers as any),
}),
body: JSON.stringify(body),
});
}
return { get, post };
async function put(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "PUT",
...init,
headers: authHeaders({
"Content-Type": "application/json",
...(init?.headers as any),
}),
body: JSON.stringify(body),
});
}
async function del(path: string, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "DELETE",
...init,
headers: authHeaders(init?.headers),
});
}
// ✅ stable reference unless token changes
return { get, post, put, del };
}, [token]);
}
+34 -9
View File
@@ -12,15 +12,40 @@ const ALWAYS_ALLOW = new Set<string>([
"/sitemap.xml",
]);
// Match common public/static file extensions so middleware never blocks them
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
const PUBLIC_FILE =
/\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
export function middleware(req: NextRequest) {
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
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 (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
@@ -31,17 +56,17 @@ export function middleware(req: NextRequest) {
return NextResponse.next();
}
// ✅ Allow the root + a few public files
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
// 🚫 Everything else: hide it
return NextResponse.rewrite(new URL("/404", req.url));
}
` 1`
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: [
// 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)).*)",
],
};
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