53 Commits

Author SHA1 Message Date
sean 8d7809ccf1 added stuff to builds/details 2025-12-26 21:04:33 -05:00
sean e202d811ed new builds/id page 2025-12-26 20:53:04 -05:00
sean c8bf032f7a set admin behind a cookie and check role on profile 2025-12-26 17:19:11 -05:00
sean 5401f6464d disable beta sign up email blasts 2025-12-24 08:34:39 -05:00
sean 57034eefc3 added enrichment ui and functionality 2025-12-23 11:18:34 -05:00
sean bb8ddb6823 small product grid cleanup 2025-12-23 06:17:34 -05:00
dstrawsb 70fac2857e fixing titles and stuff 2025-12-22 23:39:12 -05:00
sean c01aabb003 cleaned up product details page. added a link on builder to the selected product details page 2025-12-22 17:31:04 -05:00
sean 061d737c6c fixing platform switcher bugs 2025-12-22 09:03:32 -05:00
sean 34e915f904 fixed layouts, routing and the platform switcher. 2025-12-22 08:36:19 -05:00
sean b1a8dae8ed added favicon 2025-12-21 07:52:16 -05:00
sean 13edcdce69 fix possible mem leaks on photos 2025-12-21 07:40:45 -05:00
sean 856b99b933 allow users to upload build images. No backend, just local previews. need to build backend 2025-12-21 07:39:32 -05:00
sean 008936ff98 fixing vault. allow users to delete their builds or update 2025-12-21 07:20:22 -05:00
sean 47d5cf239d magic link support and forgot password 2025-12-20 18:58:25 -05:00
sean 8d9013d0dd beta sign up sends an email with a magic token. then a separate email to login. 2025-12-20 17:15:10 -05:00
sean c06696e66d fixed an vault edit error. 2025-12-20 12:24:40 -05:00
sean 54ea3243fd Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2025-12-20 10:01:22 -05:00
sean 61935982b3 new pages for user buils (vault), edit saved builds, and the community build page. 2025-12-20 10:01:07 -05:00
dstrawsb b224e2d0e0 new email management page 2025-12-19 23:16:50 -05:00
sean f81974cc0b pass builds from vault to builder 2025-12-19 08:36:48 -05:00
sean b76ced45f1 wired up an accounts page and my builds (my vault) 2025-12-19 08:05:03 -05:00
sean c6d1bce771 setting up dark/white mode. fixed some other small things 2025-12-18 21:06:08 -05:00
sean 7f3818f795 ui using new v1 api for products 2025-12-18 19:18:15 -05:00
sean 463fd06a12 Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2025-12-18 11:18:53 -05:00
sean 4c2d767d09 lots of fixes. cant remember them all 2025-12-18 09:42:25 -05:00
dstrawsb 10c0bd953b emailing html now for formatted messages 2025-12-15 22:04:48 -05:00
sean 607939c468 a metric shit ton. fixed a lot of part_role issues. removed almost all hardcoded categories and used db roles. 2025-12-15 20:59:19 -05:00
sean 0f10ff4e09 fixed part role calls and some other stuff 2025-12-13 10:07:11 -05:00
sean 978b9df96c fixing role ,apping. added accessories to ui 2025-12-13 06:02:05 -05:00
dstrawsb 3ddb48fe58 put adminleftnavigation in a component, toi unclutter the page 2025-12-12 23:19:33 -05:00
dstrawsb e200667611 added a feature for sending mail from within the application 2025-12-12 22:39:59 -05:00
dstrawsb 4e74a38287 modifed admin dashboard 2025-12-12 12:57:02 -05:00
dstrawsb 7f59e8fb27 Merge branch 'develop' of ssh://gitea.gofwd.group:2225/sean/shadow-gunbuilder-ai-proto into develop 2025-12-12 12:28:45 -05:00
dstrawsb e16271663e fixing emails listing 2025-12-12 12:28:31 -05:00
sean e2d0fb8003 update middleware to allow static images. created prod env file 2025-12-12 11:26:39 -05:00
sean 1757aba3e7 fixed authcontext for sign up, added 404 and middleware for prod readiness 2025-12-12 11:10:49 -05:00
sean 777618f684 fixed platform selector and part selector 2025-12-12 07:15:27 -05:00
sean b26dcb947e fixing platform routing. not done. updated logo on builder 2025-12-12 07:15:27 -05:00
dstrawsb 54c30b1d8a adding email, not in menus yet 2025-12-11 22:11:55 -05:00
dstrawsb 1c4fbed34a fixed a label 2025-12-09 15:12:35 -05:00
dstrawsb 8b6d9a015e fixed a label 2025-12-09 15:11:49 -05:00
sean 7c2206ffc3 fixed logo, fixed product details page image and api 2025-12-09 06:11:50 -05:00
sean 6b0e0334b0 working brevo on homepage 2025-12-08 11:41:01 -05:00
sean 2cd871b529 lots of admin stuff. admin layout, user mangement page, new landing page, etc 2025-12-08 07:08:54 -05:00
sean ce05593127 Merge branch 'it-works' into develop 2025-12-07 16:54:37 -05:00
sean 9e5b86967b new admin dashbioard and mapping page 2025-12-07 16:18:28 -05:00
sean 6cf3ea7c94 see /admin/mapping page. 2025-12-07 09:57:46 -05:00
sean caf4817d12 role-mapping-fixes 2025-12-06 20:08:00 -05:00
sean 05b30d8682 fixing url issues with renaming 2025-12-06 09:16:37 -05:00
dstrawsb be03424e26 fixed the name change from gunbuilder to builder and added the TM for trademark 2025-12-05 23:32:23 -05:00
dstrawsb fa710d35a1 fixed conflict 2025-12-05 22:28:40 -05:00
dstrawsb dbcd06d192 fixing conflicts 2025-12-05 22:25:55 -05:00
107 changed files with 13702 additions and 3355 deletions
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
+10
View File
@@ -0,0 +1,10 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
BACKEND_BASE_URL=http://localhost:8080
# Middleware to limited site to only root page
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
# Brevo API key for beta sign up collection
BREVO_API_KEY=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
BREVO_LIST_ID=9
+9
View File
@@ -0,0 +1,9 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
# Middleware to limited site to only root page
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
# Brevo API key for beta sign up collection
BREVO_API_KEY=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
BREVO_LIST_ID=9
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="postgres@r710.dev.gofwd.group" uuid="54ed6f82-1373-4766-8855-7dd5f698a5ec">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://r710.dev.gofwd.group:5433/postgres</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" value="54ed6f82-1373-4766-8855-7dd5f698a5ec" />
</component>
</project>
View File
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/schema-example.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>
Generated
+2 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
</project>
+6 -6
View File
@@ -11,7 +11,7 @@ This is an internal tool built for speed, clarity, and capability.
# 🚀 What This Admin UI Does
## Merchant Feeds
Path: `/gunbuilder/admin/merchants`
Path: `//admin/merchants`
A clean dashboard for all merchants feeding the system.
@@ -26,7 +26,7 @@ Purpose: keep the catalog alive, current, and trustworthy.
---
## Category Mapping
Path: `/gunbuilder/admin/category-mapping`
Path: `/builder/admin/category-mapping`
This is where raw merchant chaos gets cleaned up.
@@ -42,7 +42,7 @@ Once mapped, future imports classify parts automatically.
## Builder Integration
The admin UI powers the consumer-facing Builder at `/gunbuilder`.
The admin UI powers the consumer-facing Builder at `/builder`.
Today:
- Load compatible parts
@@ -80,13 +80,13 @@ pnpm install
Create `.env.local`:
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
NEXT_PUBLIC_ADMIN_BASE_PATH=/gunbuilder/admin
NEXT_PUBLIC_ADMIN_BASE_PATH=/builder/admin
## Start Dev Server
npm run dev
Then visit:
http://localhost:3000/gunbuilder/admin
http://localhost:3000/builder/admin
---
@@ -103,7 +103,7 @@ If CORS fails, the admin cannot communicate with the API.
# 🧭 Project Structure
app/
gunbuilder/
builder/
admin/
merchants/
category-mapping/
+14
View File
@@ -0,0 +1,14 @@
// app/(account)/AccountChrome.tsx
"use client";
import TopNav from "@/components/TopNav";
import BuilderNav from "@/components/BuilderNav";
export default function AccountChrome() {
return (
<>
<TopNav />
<BuilderNav />
</>
);
}
+439
View File
@@ -0,0 +1,439 @@
// app/(account)/account/page.tsx
"use client";
import { useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export default function AccountPage() {
const { user, loading, token, setSession, logout } = useAuth();
const searchParams = useSearchParams();
const router = useRouter();
const welcome = searchParams.get("welcome") === "1";
const needsDisplayName = useMemo(() => {
return !user?.displayName || user.displayName.trim().length === 0;
}, [user?.displayName]);
// Only show welcome panel if they actually need setup
const showWelcomeCard = welcome && needsDisplayName;
// ----------------------------
// Display Name state (shared)
// ----------------------------
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
// Inline profile editor
const [editingName, setEditingName] = useState(false);
// ----------------------------
// Password state
// ----------------------------
const [showPassword, setShowPassword] = useState(false);
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
const canSaveName = useMemo(() => {
return displayName.trim().length > 0 && !saving;
}, [displayName, saving]);
const pwMismatch = useMemo(
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
[pw1, pw2]
);
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
const canSavePassword = useMemo(() => {
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
}, [pwSaving, pw1, pw2]);
if (loading) return <div className="text-sm opacity-70">Loading</div>;
if (!user) {
return (
<div className="text-sm opacity-70">
Youre not logged in. Please log in to view your account.
</div>
);
}
const authedUser = user; // user is non-null from here down
async function saveDisplayName() {
setSaving(true);
setMsg(null);
try {
const nextName = displayName.trim();
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ displayName: nextName }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Failed to update profile");
}
const updated = await res.json().catch(() => null);
const nextUser = {
email: authedUser.email,
displayName: updated?.displayName ?? nextName,
role: authedUser.role,
};
if (token) setSession(token, nextUser);
setMsg("Saved ✅");
setEditingName(false);
// If we came from the welcome flow, clear the param
if (welcome) router.replace("/account");
} catch (e: any) {
setMsg(e?.message || "Couldnt save. Try again.");
} finally {
setSaving(false);
}
}
async function savePassword() {
setPwMsg(null);
if (pw1.length < 8) {
setPwMsg("Password must be at least 8 characters.");
return;
}
if (pw1 !== pw2) {
setPwMsg("Passwords dont match.");
return;
}
setPwSaving(true);
try {
const res = await fetch(`${API_BASE_URL}/api/users/me/password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ password: pw1 }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Failed to set password");
}
setPwMsg("Password set ✅ You can now use email + password login.");
setPw1("");
setPw2("");
setShowPassword(false);
if (welcome) router.replace("/account");
} catch (e: any) {
setPwMsg(e?.message || "Couldnt set password. Try again.");
} finally {
setPwSaving(false);
}
}
return (
<div className="space-y-6">
{/* Welcome / Setup card (only if needed) */}
{showWelcomeCard && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
<div>
<h2 className="text-base font-semibold">Welcome to the beta 👋</h2>
<p className="text-sm text-zinc-300/80">
Quick setup and youre ready to cook.
</p>
</div>
{/* Display name */}
<div className="space-y-2">
<label className="text-xs font-medium text-zinc-200">
Display name
</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<div className="flex items-center gap-3">
<button
onClick={saveDisplayName}
disabled={!canSaveName}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
</button>
{msg && (
<span
className={`text-xs ${
msg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{msg}
</span>
)}
</div>
</div>
{/* Password */}
<div className="pt-2 border-t border-white/10">
<p className="text-xs text-zinc-300/80">
Want password login later? Optional (but recommended).
</p>
{!showPassword ? (
<button
onClick={() => setShowPassword(true)}
className="mt-2 text-xs underline opacity-80 hover:opacity-100"
>
Set a password
</button>
) : (
<div className="mt-3 space-y-2">
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="New password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<input
type="password"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Confirm password"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort
? "Password must be at least 8 characters."
: "Passwords dont match."}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
<button
onClick={() => {
setShowPassword(false);
setPw1("");
setPw2("");
setPwMsg(null);
}}
className="text-xs opacity-80 hover:opacity-100"
>
Cancel
</button>
</div>
{pwMsg && (
<div
className={`text-xs ${
pwMsg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{pwMsg}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Profile header */}
<div>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm opacity-70">Basic account info.</p>
</div>
{/* Profile card */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="text-sm">
<span className="opacity-70">Email:</span>{" "}
<span className="font-medium">{user.email}</span>
</div>
{/* Display name (inline editable) */}
<div className="text-sm">
<span className="opacity-70">Display name:</span>{" "}
{!editingName ? (
<>
<span className="font-medium">{user.displayName || "—"}</span>
<button
onClick={() => {
setEditingName(true);
setMsg(null);
setDisplayName(user.displayName ?? "");
}}
className="ml-3 text-xs underline opacity-70 hover:opacity-100"
>
Edit
</button>
</>
) : (
<div className="mt-2 space-y-2">
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your display name"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<div className="flex items-center gap-3">
<button
onClick={saveDisplayName}
disabled={!canSaveName}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
</button>
<button
onClick={() => {
setEditingName(false);
setDisplayName(user.displayName ?? "");
setMsg(null);
}}
className="text-xs opacity-80 hover:opacity-100"
>
Cancel
</button>
{msg && (
<span
className={`text-xs ${
msg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{msg}
</span>
)}
</div>
</div>
)}
</div>
<div className="text-sm">
<span className="opacity-70">Role:</span>{" "}
<span className="font-medium">{user.role}</span>
</div>
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
<button
onClick={logout}
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
>
Log out
</button>
{/* Optional: quick link to set password later */}
<button
onClick={() => {
setShowPassword(true);
// Scroll users attention to welcome panel area if it exists,
// otherwise just open the password UI in their mind.
// (Kept simple: you can add a dedicated card later if you want)
setPwMsg(null);
}}
className="text-xs underline opacity-70 hover:opacity-100"
>
Set / change password
</button>
</div>
{/* If user clicks "Set/change password" outside welcome flow, show it here */}
{!showWelcomeCard && showPassword && (
<div className="pt-4 border-t border-white/10 space-y-2">
<div className="text-sm font-medium">Set password</div>
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="New password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<input
type="password"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Confirm password"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort
? "Password must be at least 8 characters."
: "Passwords dont match."}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
<button
onClick={() => {
setShowPassword(false);
setPw1("");
setPw2("");
setPwMsg(null);
}}
className="text-xs opacity-80 hover:opacity-100"
>
Cancel
</button>
</div>
{pwMsg && (
<div
className={`text-xs ${
pwMsg.toLowerCase().includes("couldn")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{pwMsg}
</div>
)}
</div>
)}
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
// app/(account)/layout.tsx
import Link from "next/link";
import AccountChrome from "./AccountChrome";
const nav = [
{ href: "/account", label: "My Account" },
{ href: "/account/settings", label: "Settings" },
{ href: "/vault", label: "My Vault" },
];
export default function AccountLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<AccountChrome />
<div className="mx-auto w-full max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
Account
</div>
<h1 className="mt-1 text-2xl font-semibold text-white/90">
Settings & Profile
</h1>
<p className="mt-1 text-sm text-white/60">
Profile, password, and account settings.
</p>
</div>
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
>
Back to Builder
</Link>
</div>
<div className="grid gap-6 lg:grid-cols-12">
<aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
<nav className="flex flex-col gap-1">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
>
{item.label}
</Link>
))}
</nav>
</aside>
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
{children}
</main>
</div>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
// app/(builder)/builder/layout.tsx
import type { ReactNode } from "react";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
File diff suppressed because it is too large Load Diff
@@ -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
/>
);
}
@@ -0,0 +1,431 @@
"use client";
import { useEffect, useMemo, useState } from "react";
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";
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type BuildState = Partial<Record<CategoryId, string>>;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const STORAGE_KEY = "gunbuilder-build-state";
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
function extractNumericId(productSlug: string): number | null {
if (!productSlug) return null;
const first = productSlug.split("-")[0];
const n = Number(first);
return Number.isFinite(n) && n > 0 ? n : null;
}
function formatPrice(price: number | null | undefined): string {
if (price == null) return "—";
return `$${price.toFixed(2)}`;
}
export default function ProductDetailsPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
// NEW ROUTE PARAMS:
const platformParam = String(params.platform ?? "");
const partRoleParam = String(params.partRole ?? "");
const productSlug = String(params.productSlug ?? "");
// Platform comes from path segment (fallback to ?platform just in case)
const platform =
platformParam ||
(searchParams.get("platform") as string) ||
"AR-15";
const normalizedRole = useMemo(
() => normalizePartRole(partRoleParam),
[partRoleParam]
);
const categoryId = useMemo(() => {
return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
}, [partRoleParam, normalizedRole]);
const numericId = useMemo(
() => extractNumericId(productSlug),
[productSlug]
);
// Read-only build state (builder page owns writes)
const [build] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
const isSelected =
!!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : "");
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// If platform segment is missing/empty, normalize into the new route (not /builder)
useEffect(() => {
if (platformParam) return;
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", platform || "AR-15");
router.replace(
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
// Fetch product
useEffect(() => {
if (!numericId) {
setError("Invalid product id.");
setLoading(false);
return;
}
const controller = new AbortController();
async function fetchProduct() {
try {
setLoading(true);
setError(null);
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`);
}
const data: GunbuilderProductFromApi = await res.json();
setProduct(data);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load product");
} finally {
setLoading(false);
}
}
fetchProduct();
return () => controller.abort();
}, [numericId]);
const handleTogglePart = () => {
if (!numericId) return;
// If mapping is missing, dont send a bogus builder action
if (!categoryId) {
alert(
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
);
return;
}
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
if (isSelected) {
qp.set("remove", String(categoryId));
router.push(`/builder?${qp.toString()}`);
return;
}
qp.set("select", `${categoryId}:${numericId}`);
router.push(`/builder?${qp.toString()}`);
};
if (!categoryId) {
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">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Unknown Part Role
</h1>
<p className="text-sm text-zinc-400 mb-6">
No mapping found for <span className="text-zinc-200">{partRoleParam}</span>.
Add it to <span className="text-zinc-200">catalogMappings</span>.
</p>
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="text-amber-300 hover:text-amber-200 underline"
>
Back to Parts List
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Breadcrumbs */}
<header className="mb-6">
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="hover:text-zinc-300"
>
The Builder
</Link>
<span className="text-zinc-700">/</span>
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="hover:text-zinc-300"
>
Parts
</Link>
<span className="text-zinc-700">/</span>
<span className="text-zinc-300">Details</span>
</div>
<div className="mt-4 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Product <span className="text-amber-300">Details</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Live product data pulled from your Ballistic backend. Add it to your build
or jump back to compare options.
</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>
{/* Body */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
{loading ? (
<p className="py-10 text-center text-sm text-zinc-500">Loading product</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : !product ? (
<p className="py-10 text-center text-sm text-zinc-500">Product not found.</p>
) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* Left: image */}
<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">
{product.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={product.imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
No image
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Stock
</span>
<span
className={`text-xs font-semibold ${
product.inStock === false ? "text-red-300" : "text-emerald-300"
}`}
>
{product.inStock === false ? "Out of stock" : "In stock"}
</span>
</div>
<div className="mt-3 flex gap-2">
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
Back to List
</Link>
<button
type="button"
onClick={handleTogglePart}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
{/* Right: details */}
<div className="min-w-0">
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
{product.brand}
</p>
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
{product.name}
</h2>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Platform:{" "}
<span className="font-semibold text-zinc-100">
{product.platform || platform}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "}
<span className="font-semibold text-zinc-100">
{product.partRole || partRoleParam}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
ID:{" "}
<span className="font-semibold text-zinc-100">
{product.id}
</span>
</span>
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Current price
</div>
<div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(product.price)}
</div>
</div>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={handleTogglePart}
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
{product.buyUrl ? (
<a
href={product.buyUrl}
target="_blank"
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-center text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
>
Buy from Merchant
</a>
) : (
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-4 py-2 text-center text-sm text-zinc-500">
No buy link available
</span>
)}
</div>
<div className="mt-4 border-t border-zinc-800 pt-4">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Notes
</h3>
<p className="mt-2 text-sm text-zinc-400">
This details page is intentionally thin for now once we wire in
offers, price history, and richer product fields, this section becomes
the spec + comparison hub.
</p>
</div>
</div>
<div className="mt-4 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">
Tip: use Back to List to compare multiple parts quickly.
</span>
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Platform comes from the URL segment:{" "}
<span className="text-zinc-300">/parts/p/{platform}/...</span>
</span>
</div>
</div>
</div>
)}
</section>
</div>
</main>
);
}
@@ -1,4 +1,4 @@
// app/gunbuilder/[categoryId]/[partId]/data.ts
// app/builder/[categoryId]/[partId]/data.ts
// Simulated data functions - Replace these with API calls when ready
// Example: export async function getPricingHistory(partId: string) { ... }
@@ -56,13 +56,13 @@ export function getPricingHistory(
/**
* Get retailer offers for a part
* Real implementation using Ballistic backend:
* GET /api/products/{productId}/offers
* GET /api/v1/products/{productId}/offers
*/
export async function getRetailerOffers(
productId: string,
): Promise<RetailerOffer[]> {
const res = await fetch(
`${API_BASE_URL}/api/products/${productId}/offers`,
`${API_BASE_URL}/api/v1/products/${productId}/offers`,
{
// This will be called from the client detail page,
// so we *don't* use cache here.
@@ -0,0 +1,846 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
type ViewMode = "card" | "list";
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type UiPart = Part & {
// local-only stock flag for filtering
inStock?: boolean;
};
const PLATFORMS = ["AR-15", "AR-10", "AR-9"];
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
// sort options
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
// support for url id-slug
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const normalizeRole = (role: string) =>
role.trim().toLowerCase().replace(/_/g, "-");
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
| string[]
| undefined;
if (!roles || roles.length === 0) return [];
return Array.from(new Set(roles.map(normalizeRole)));
}
export default function CategoryPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const partRoles = useMemo(
() => getNormalizedPartRolesForCategory(String(categoryId)),
[categoryId]
);
const router = useRouter();
const searchParams = useSearchParams();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// filter + sort state
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>("");
const [priceRange, setPriceRange] = useState<{
min: number | null;
max: number | null;
}>({
min: null,
max: null,
});
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number>(1);
const PAGE_SIZE = 24;
// build state for this page (read-only), hydrated from localStorage
// NOTE: The main /builder page is the single source of truth for writing to STORAGE_KEY.
const [build] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
const [platform, setPlatform] = useState<string>(initialPlatform);
// If a user lands here without ?platform=..., normalize the URL so downstream navigation keeps platform.
useEffect(() => {
const qpPlatform = searchParams.get("platform");
if (qpPlatform) return;
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", platform || "AR-15");
router.replace(`/builder/${categoryId}?${qp.toString()}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryId, router, searchParams]);
const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId]
);
useEffect(() => {
if (!categoryId) return;
const controller = new AbortController();
async function fetchCategoryParts() {
try {
setLoading(true);
setError(null);
const search = new URLSearchParams();
search.set("platform", platform);
// ✅ Scope results to this category's backend roles
for (const r of partRoles) {
search.append("partRoles", r);
}
const url = `${API_BASE_URL}/api/v1/products?${search.toString()}`;
const res = await fetch(url, { 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) => ({
id: String(p.id), // normalize to string for BuildState
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.imageUrl ?? undefined, // match backend field name
url: p.buyUrl ?? undefined,
notes: undefined,
inStock: p.inStock ?? true,
}));
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchCategoryParts();
return () => controller.abort();
}, [categoryId, platform, partRoles]);
// handler to toggle Add / Remove for this category
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
if (isSelected) {
// Tell the main builder to remove this category selection
qp.set("remove", String(categoryId));
router.push(`/builder?${qp.toString()}`);
return;
}
// Tell the main builder to add/replace this category selection
qp.set("select", `${categoryId}:${partId}`);
router.push(`/builder?${qp.toString()}`);
};
// available brands for filter
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
[parts]
);
const priceBounds = useMemo(() => {
if (parts.length === 0) {
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 };
}, [parts]);
// initialize / clamp priceRange whenever bounds change
useEffect(() => {
if (priceBounds.min == null || priceBounds.max == null) return;
setPriceRange((prev) => {
const min = prev.min ?? priceBounds.min!;
const max = prev.max ?? priceBounds.max!;
return {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
};
});
}, [priceBounds.min, priceBounds.max]);
const filteredParts = useMemo(() => {
let result = [...parts];
// Price range filter
const effectiveMin =
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
const effectiveMax =
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
if (effectiveMin != null && effectiveMax != null) {
result = result.filter((p) => {
const price = p.price ?? 0;
return price >= effectiveMin && price <= effectiveMax;
});
}
// Brand filter
if (brandFilter.length > 0) {
result = result.filter((p) => brandFilter.includes(p.brand));
}
// In-stock filter
if (inStockOnly) {
result = result.filter((p) => p.inStock ?? true);
}
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery) {
result = result.filter((p) => {
const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase();
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
});
}
switch (sortBy) {
case "price-asc":
result.sort((a, b) => a.price - b.price);
break;
case "price-desc":
result.sort((a, b) => b.price - a.price);
break;
case "brand-asc":
result.sort((a, b) => a.brand.localeCompare(b.brand));
break;
case "relevance":
default:
// leave in backend order
break;
}
// Pin the currently selected part (for this category) to the top, if any
const selectedId = build[categoryId];
if (selectedId) {
const selected = result.filter((p) => p.id === selectedId);
const others = result.filter((p) => p.id !== selectedId);
result = [...selected, ...others];
}
return result;
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceBounds, priceRange, inStockOnly]);
const totalPages = useMemo(
() =>
filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE),
[filteredParts, 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, PAGE_SIZE]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
useEffect(() => {
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
setPlatform(nextPlatform);
}, [searchParams]);
// Keep selection highlight in sync if the build is changed elsewhere and user comes back.
useEffect(() => {
if (typeof window === "undefined") return;
const onStorage = (e: StorageEvent) => {
if (e.key !== STORAGE_KEY) return;
// Force a re-render by reading current storage (since build state is read-only)
// This keeps the selected row pinned/highlighted when navigating back.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
window.localStorage.getItem(STORAGE_KEY);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
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, PAGE_SIZE]);
if (!category) {
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">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Category Not Found
</h1>
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Builder
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to the Builder
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
{category.name} <span className="text-amber-300">Parts</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Browse all available {category.name.toLowerCase()} options for your
build, pulled live from your Ballistic backend.
</p>
</div>
{!loading && !error && parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="Card view"
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="List view"
>
List
</button>
</div>
)}
</div>
</header>
{/* Parts Grid/List */}
<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">
{/* Left sidebar: filters */}
{!loading && !error && parts.length > 0 && (
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the {category.name.toLowerCase()} list.
</p>
</div>
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{priceRange.min !== null || priceRange.max !== null ? (
<button
type="button"
onClick={() =>
setPriceRange({ min: priceBounds.min, max: priceBounds.max })
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Reset
</button>
) : null}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">
No price data available.
</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: Math.min(value, prev.max ?? priceBounds.max ?? value),
max: prev.max ?? priceBounds.max ?? value,
}));
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: prev.min ?? priceBounds.min ?? value,
max: Math.max(value, prev.min ?? priceBounds.min ?? value),
}));
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">
No brands available yet.
</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter((prev) =>
e.target.checked
? [...prev, b]
: prev.filter((brand) => brand !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</aside>
)}
{/* Right side: toolbar + results */}
<div className="flex-1">
{/* Top toolbar: count + search + sort */}
{!loading && !error && parts.length > 0 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{filteredParts.length}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label htmlFor="part-search" className="text-xs text-zinc-500">
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${category.name.toLowerCase()}...`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<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;
setPlatform(next);
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next);
router.replace(`/builder/${categoryId}?${qp.toString()}`);
}}
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 className="flex items-center gap-2">
<label htmlFor="sort-by" className="text-xs text-zinc-500">
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
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"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div>
)}
{/* Results / states */}
{loading ? (
<p className="py-8 text-center text-sm text-zinc-500">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="mb-3 flex items-center gap-2 justify-between">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal"> {part.name}</span>
</div>
{part.notes && (
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
<div className="mt-2 flex gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${part.id}-${slugify(
part.name
)}`,
query: { platform },
}}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() => handleTogglePart(categoryId, part.id)}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
);
})}
</div>
) : (
<>
{/* Header row for list view */}
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
<span>Part</span>
<span>Brand</span>
<span className="text-right">Price</span>
<span className="pr-2 text-right">Actions</span>
</div>
<div className="space-y-2">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.name}
</div>
{part.notes && (
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)}
</div>
<div className="flex justify-end gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${part.id}-${slugify(
part.name
)}`,
query: { platform },
}}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() => handleTogglePart(categoryId, part.id)}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{/* Pagination controls */}
{filteredParts.length > 0 && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</section>
</div>
</main>
);
}
@@ -0,0 +1,14 @@
import { redirect } from "next/navigation";
export default async function PartsRoleRedirectPage({
params,
searchParams,
}: {
params: { partRole: string };
searchParams?: { platform?: string };
}) {
const partRole = params.partRole;
const platform = searchParams?.platform ?? "AR-15";
redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`);
}
@@ -0,0 +1,24 @@
// app/(builder)/parts/_components/buildDetailHref.ts
const toSlug = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
export type BuildDetailInput = {
id: string;
name: string;
brand?: string;
};
export function buildDetailHref(
platform: string,
partRole: string,
p: BuildDetailInput
) {
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRole
)}/${p.id}-${slug}`;
}
@@ -0,0 +1,750 @@
"use client";
import { useEffect, useMemo, useState } from "react";
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";
/**
* API Shapes
* - 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;
};
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
// Optional (legacy fallback label if product.offers is missing)
merchantName?: string | null;
imageUrl?: string | null;
mainImageUrl?: string | null;
buyUrl?: string | null;
inStock?: boolean | null;
shortDescription?: string | null;
description?: string | null;
// (May be empty depending on endpoint; we now fetch offers separately)
offers?: OfferFromApi[] | null;
};
type BuildState = Partial<Record<CategoryId, string>>;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const STORAGE_KEY = "gunbuilder-build-state";
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
/**
* Extract numeric id from slug like: "1217-radian-ar-15..."
* This keeps URLs pretty while still letting us fetch by ID.
*/
function extractNumericId(productSlug: string): number | null {
if (!productSlug) return null;
const first = productSlug.split("-")[0];
const n = Number(first);
return Number.isFinite(n) && n > 0 ? n : null;
}
function formatPrice(price: number | null | undefined): string {
if (price == null) return "—";
return `$${price.toFixed(2)}`;
}
/**
* Derive a friendly merchant label.
* Priority:
* 1) offer.merchantName (best)
* 2) hostname from offer.buyUrl (fallback)
* 3) "Retailer"
*/
function getHostFromUrl(url?: string | null): string | null {
if (!url) return null;
try {
const u = new URL(url);
return u.host.replace(/^www\./, "");
} catch {
return null;
}
}
function toTitleCase(s: string): string {
return s
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function merchantLabel(offer: OfferFromApi): string {
const name = offer.merchantName?.trim();
if (name) return name;
const host = getHostFromUrl(offer.buyUrl);
if (host) return toTitleCase(host.split(".")[0]);
return "Retailer";
}
/**
* Sort offers:
* 1) lowest price first
* 2) in-stock before out-of-stock
* 3) merchant label tiebreaker
*/
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
return [...offers].sort((a, b) => {
const ap = a.price ?? Number.POSITIVE_INFINITY;
const bp = b.price ?? Number.POSITIVE_INFINITY;
if (ap !== bp) return ap - bp;
// in-stock first (false sorts later)
const aStock = a.inStock === false ? 1 : 0;
const bStock = b.inStock === false ? 1 : 0;
if (aStock !== bStock) return aStock - bStock;
return merchantLabel(a).localeCompare(merchantLabel(b));
});
}
export default function ProductDetailsPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
// Route params
const platformParam = String(params.platform ?? "");
const partRoleParam = String(params.partRole ?? "");
const productSlug = String(params.productSlug ?? "");
// Platform comes from path segment (fallback to ?platform just in case)
const platform =
platformParam || (searchParams.get("platform") as string) || "AR-15";
const normalizedRole = useMemo(
() => normalizePartRole(partRoleParam),
[partRoleParam]
);
/**
* categoryId drives builder state selection/removal
* (based on partRole -> category mapping).
*/
const categoryId = useMemo(() => {
return (
PART_ROLE_TO_CATEGORY[partRoleParam] ??
PART_ROLE_TO_CATEGORY[normalizedRole] ??
null
);
}, [partRoleParam, normalizedRole]);
const numericId = useMemo(() => extractNumericId(productSlug), [productSlug]);
// Read-only build state (builder page owns writes)
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
// Keep build in sync if user changes build elsewhere
useEffect(() => {
if (typeof window === "undefined") return;
const onStorage = (e: StorageEvent) => {
if (e.key !== STORAGE_KEY) return;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
} catch {
// ignore
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
const isSelected =
!!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : "");
// Product fetch state
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
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.
*/
useEffect(() => {
if (platformParam) return;
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", platform || "AR-15");
router.replace(
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
/**
* Fetch product details from API:
* GET /api/v1/products/:id
*/
useEffect(() => {
if (!numericId) {
setError("Invalid product id.");
setLoading(false);
return;
}
const controller = new AbortController();
async function fetchProduct() {
try {
setLoading(true);
setError(null);
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`);
}
const data: GunbuilderProductFromApi = await res.json();
setProduct(data);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load product");
} finally {
setLoading(false);
}
}
fetchProduct();
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
* - Else -> add to build
*/
const handleTogglePart = () => {
if (!numericId) return;
if (!categoryId) {
alert(
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
);
return;
}
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
if (isSelected) {
qp.set("remove", String(categoryId));
router.push(`/builder?${qp.toString()}`);
return;
}
qp.set("select", `${categoryId}:${numericId}`);
router.push(`/builder?${qp.toString()}`);
};
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
/**
* Offers normalization:
* - Prefer offers endpoint data
* - Fallback to legacy single-offer derived from product if needed
*/
const offers = useMemo(() => {
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
if (product?.buyUrl) {
return sortOffers([
{
merchantName: product.merchantName ?? "Retailer",
price: product.price,
buyUrl: product.buyUrl,
inStock: product.inStock ?? true,
},
]);
}
return [];
}, [offersFromApi, product]);
/**
* Best offer (sorted offers[0])
*/
const bestOffer = offers[0] ?? null;
/**
* Helper: identify the best offer row
*/
const isBestOffer = (offer: OfferFromApi) =>
!!bestOffer &&
offer.id != null &&
bestOffer.id != null &&
String(offer.id) === String(bestOffer.id);
useEffect(() => {
console.log("OFFERS", offers);
console.log("BEST", bestOffer);
}, [offers, bestOffer]);
// Use best offer for “truth” when available
const stockValue = bestOffer?.inStock ?? product?.inStock ?? null;
const currentPrice = bestOffer?.price ?? product?.price ?? null;
if (!categoryId) {
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">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Unknown Part Role
</h1>
<p className="text-sm text-zinc-400 mb-6">
No mapping found for{" "}
<span className="text-zinc-200">{partRoleParam}</span>. Add it to{" "}
<span className="text-zinc-200">catalogMappings</span>.
</p>
<Link
href={`/parts/p/${encodeURIComponent(
platform
)}/${encodeURIComponent(partRoleParam)}`}
className="text-amber-300 hover:text-amber-200 underline"
>
Back to Parts List
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Breadcrumbs */}
<header className="mb-6">
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<Link
href={{
pathname: "/builder",
query: platform ? { platform } : {},
}}
className="hover:text-zinc-300"
>
Builder
</Link>
<span className="text-zinc-700">/</span>
<Link
href={`/parts/p/${encodeURIComponent(
platform
)}/${encodeURIComponent(partRoleParam)}`}
className="hover:text-zinc-300"
>
Parts
</Link>
<span className="text-zinc-700">/</span>
<span className="text-zinc-300">Details</span>
</div>
<div className="mt-4 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Product <span className="text-amber-300">Details</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Offers, pricing placeholders, and builder actions all on the
canonical /parts/p route.
</p>
</div>
</div>
</header>
{/* Body */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
{loading ? (
<p className="py-10 text-center text-sm text-zinc-500">
Loading product
</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : !product ? (
<p className="py-10 text-center text-sm text-zinc-500">
Product not found.
</p>
) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* 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 ? (
<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
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Stock
</span>
<span
className={`text-xs font-semibold ${
stockValue === false
? "text-red-300"
: "text-emerald-300"
}`}
>
{stockValue === false ? "Out of stock" : "In stock"}
</span>
</div>
<div className="mt-3 flex gap-2">
<Link
href={`/parts/p/${encodeURIComponent(
platform
)}/${encodeURIComponent(partRoleParam)}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
Back to List
</Link>
<button
type="button"
onClick={handleTogglePart}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove" : "Add to Build"}
</button>
</div>
</div>
{/* 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>
</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
</div>
<p className="mt-2 text-[11px] text-zinc-500">
Well wire this to price snapshots once the offer model is
stable.
</p>
</div>
</div>
{/* 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">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
{product.brand}
</p>
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
{product.name}
</h2>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Platform:{" "}
<span className="font-semibold text-zinc-100">
{product.platform || platform}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "}
<span className="font-semibold text-zinc-100">
{product.partRole || partRoleParam}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
ID:{" "}
<span className="font-semibold text-zinc-100">
{product.id}
</span>
</span>
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Current price
</div>
<div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(currentPrice)}
</div>
</div>
</div>
{(product.shortDescription || product.description) && (
<div className="mt-4 border-t border-zinc-800 pt-4">
{product.shortDescription ? (
<p className="text-sm text-zinc-300">
{product.shortDescription}
</p>
) : null}
{product.description ? (
<p className="mt-2 whitespace-pre-wrap text-sm text-zinc-400">
{product.description}
</p>
) : null}
</div>
)}
</div>
{/* Offers */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Merchant Offers
</h3>
<span className="text-[10px] text-zinc-600">
{offersLoading
? "loading…"
: offers.length
? `${offers.length} offers`
: "no offers"}
</span>
</div>
{offers.length ? (
<div className="mt-3 overflow-x-auto rounded border border-zinc-800">
<table className="min-w-full text-sm">
<thead className="bg-zinc-900/60">
<tr className="text-xs uppercase tracking-[0.14em] text-zinc-500">
<th className="px-3 py-2 text-left">Merchant</th>
<th className="px-3 py-2 text-left">Stock</th>
<th className="px-3 py-2 text-right">Price</th>
<th className="px-3 py-2 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{offers.map((o, idx) => (
<tr
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>{merchantLabel(o)}</span>
{isBestOffer(o) && (
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
BEST DEAL
</span>
)}
</td>
<td className="px-3 py-2 text-xs">
{o.inStock === false ? (
<span className="text-red-300">
Out of stock
</span>
) : (
<span className="text-emerald-300">
In stock
</span>
)}
</td>
<td className="px-3 py-2 text-right font-semibold text-amber-300">
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td>
<td className="px-3 py-2 text-right">
{o.buyUrl ? (
<a
href={o.buyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
>
Buy
</a>
) : (
<span className="text-xs text-zinc-600"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">
No offers attached yet. Once offers are available, this
becomes the where to buy table.
</p>
)}
{/* Best Offer CTA */}
{bestOffer?.buyUrl ? (
<div className="mt-3 flex justify-end">
<a
href={bestOffer.buyUrl}
target="_blank"
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 {merchantLabel(bestOffer)}
</a>
</div>
) : null}
</div>
{/* 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">
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">
Canonical route:{" "}
<span className="text-zinc-300">
/parts/p/{platform}/{partRoleParam}/{productSlug}
</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>
);
}
@@ -0,0 +1,5 @@
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
}
+15
View File
@@ -0,0 +1,15 @@
// app/(builder)/layout.tsx
import type { ReactNode } from "react";
import { BuilderNav } from "@/components/BuilderNav";
import { TopNav } from "@/components/TopNav";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
<TopNav />
<BuilderNav />
<main className="min-h-screen">{children}</main>
</div>
);
}
+616
View File
@@ -0,0 +1,616 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = {
uuid: string;
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
items?: Array<{
slot: string;
productId: number;
productName?: string | null;
productBrand?: string | null;
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
}>;
photos?: BuildPhotoDto[];
};
type BuildPhotoDto = {
uuid: string;
url: string;
caption?: string | null;
sortOrder?: number | null;
createdAt?: string | null;
};
type LocalPreview = {
id: string;
file: File;
url: string; // object URL
};
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
}
export default function VaultBuildEditPage() {
const { uuid } = useParams<{ uuid: string }>();
const router = useRouter();
const api = useApi();
const { token, loading: authLoading } = useAuth();
const [build, setBuild] = useState<BuildDto | null>(null);
const [form, setForm] = useState({
title: "",
description: "",
isPublic: false,
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
// photo upload UI state
const [previews, setPreviews] = useState<LocalPreview[]>([]);
const [uploading, setUploading] = useState(false);
// avoid object URL leaks
useEffect(() => {
return () => {
previews.forEach((p) => URL.revokeObjectURL(p.url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const authed = !!token;
// Load build once auth is ready + token exists
useEffect(() => {
if (!uuid) return;
if (authLoading) return;
if (!authed) {
setError("Please log in to edit builds.");
return;
}
let cancelled = false;
(async () => {
try {
setError(null);
const res = await api.get(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load build (${res.status})`);
}
const data = (await res.json()) as BuildDto;
if (cancelled) return;
setBuild(data);
setForm({
title: data.title ?? "",
description: data.description ?? "",
isPublic: Boolean(data.isPublic),
});
} catch (e: any) {
if (!cancelled) setError(e?.message ?? "Failed to load build");
}
})();
return () => {
cancelled = true;
};
}, [uuid, authLoading, authed, api]);
const dirty = useMemo(() => {
if (!build) return false;
return (
form.title !== (build.title ?? "") ||
form.description !== (build.description ?? "") ||
form.isPublic !== Boolean(build.isPublic)
);
}, [build, form]);
async function onSave() {
if (!uuid) return;
setBusy(true);
setSavedMsg(null);
setError(null);
try {
const payload = {
title: form.title.trim() || "Untitled Build",
description: form.description.trim() || null,
isPublic: form.isPublic,
};
const res = await api.put(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`,
payload
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Save failed (${res.status})`);
}
const updated = (await res.json()) as BuildDto;
setBuild(updated);
setForm({
title: updated.title ?? "",
description: updated.description ?? "",
isPublic: Boolean(updated.isPublic),
});
setSavedMsg("Saved.");
setTimeout(() => setSavedMsg(null), 2000);
} catch (e: any) {
setError(e?.message ?? "Save failed");
} finally {
setBusy(false);
}
}
function isImage(file: File) {
return file.type.startsWith("image/");
}
async function fileToUpload(file: File, buildUuid: string) {
// 1) ask backend for presigned upload URL
const res = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`,
{
fileName: file.name,
contentType: file.type,
}
);
if (!res.ok) {
throw new Error(await res.text().catch(() => "Failed to get upload URL"));
}
const { uploadUrl, publicUrl } = await res.json();
// 2) upload directly to storage
const put = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error("Upload failed");
// 3) confirm + create photo record
const create = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`,
{
url: publicUrl,
caption: null,
sortOrder: 0,
}
);
if (!create.ok) {
throw new Error(await create.text().catch(() => "Failed to save photo"));
}
return (await create.json()) as BuildPhotoDto;
}
async function onDelete() {
if (!uuid) return;
const ok = window.confirm("Delete this build?\n\nThis cannot be undone.");
if (!ok) return;
setBusy(true);
setError(null);
try {
const res = await api.del(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Delete failed (${res.status})`);
}
router.replace("/vault");
router.refresh();
} catch (e: any) {
setError(e?.message ?? "Delete failed");
} finally {
setBusy(false);
}
}
return (
<main className="mx-auto max-w-3xl p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Edit Build</h1>
<div className="mt-1 text-xs text-zinc-500">
UUID: <span className="font-mono">{uuid}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link
href="/vault"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
>
Vault
</Link>
<button
className="rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
disabled={!uuid}
onClick={() =>
router.push(`/builder?load=${encodeURIComponent(uuid)}`)
}
title="Open this build in the builder to swap parts"
>
Open in Builder
</button>
</div>
</div>
{authLoading && <div className="text-sm text-zinc-400">Loading</div>}
{!authLoading && error && (
<pre className="mb-4 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
{error}
</pre>
)}
{!authLoading && build && (
<div className="space-y-4">
{/* meta */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-zinc-400">
Created: {fmt(build.createdAt)} Updated:{" "}
{fmt(build.updatedAt)}
</div>
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${
form.isPublic
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
>
{form.isPublic ? "Published" : "Draft"}
</span>
</div>
</div>
{/* parts */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold">Parts in this build</div>
<div className="text-xs text-zinc-400">
{build.items?.length ? `${build.items.length} items` : "—"}
</div>
</div>
{!build.items?.length ? (
<div className="text-sm opacity-70">No parts saved yet.</div>
) : (
<div className="overflow-hidden rounded-lg border border-white/10 bg-black/20">
{/* header */}
<div className="grid grid-cols-12 gap-3 border-b border-white/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
<div className="col-span-5">Product</div>
<div className="col-span-4">Component (Role)</div>
<div className="col-span-2">Brand</div>
<div className="col-span-1 text-right">Price</div>
</div>
{/* rows */}
{build.items
.slice()
.sort((a, b) => a.slot.localeCompare(b.slot))
.map((it, idx) => {
const price =
typeof it.bestPrice === "number"
? it.bestPrice
: it.bestPrice
? Number(it.bestPrice)
: null;
return (
<div
key={`${it.slot}-${it.productId}-${idx}`}
className="grid grid-cols-12 gap-2 border-b border-white/5 px-3 py-2 text-sm last:border-b-0"
>
{/* Product */}
<div className="col-span-5 min-w-0">
<div className="truncate text-zinc-100">
{it.productName ?? `Product #${it.productId}`}
</div>
<div className="text-xs text-zinc-500">
ID:{" "}
<span className="font-mono">{it.productId}</span>
</div>
</div>
{/* Component (Role) */}
<div className="col-span-4 truncate text-zinc-200">
{it.slot}
</div>
{/* Brand */}
<div className="col-span-2 truncate text-zinc-300">
{it.productBrand ?? "—"}
</div>
{/* Price */}
<div className="col-span-1 text-right font-semibold text-zinc-100">
{price != null && !Number.isNaN(price)
? `$${price.toFixed(2)}`
: "—"}
</div>
</div>
);
})}
</div>
)}
</div>
{/* form */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<label className="block text-xs font-medium text-zinc-300">
Title
</label>
<input
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
className="mt-2 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
placeholder="e.g. Suppressed MK2"
maxLength={120}
/>
<label className="mt-4 block text-xs font-medium text-zinc-300">
Notes / Description
</label>
<textarea
value={form.description}
onChange={(e) =>
setForm((f) => ({ ...f, description: e.target.value }))
}
className="mt-2 min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
maxLength={2000}
/>
<div className="mt-4 flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.isPublic}
onChange={(e) =>
setForm((f) => ({ ...f, isPublic: e.target.checked }))
}
/>
<span className="text-zinc-200">Publish to community</span>
</label>
<div className="flex items-center gap-2">
{savedMsg && (
<span className="text-xs text-emerald-300">{savedMsg}</span>
)}
<button
onClick={onSave}
disabled={busy || !dirty}
className="rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black hover:bg-zinc-200 disabled:opacity-50"
>
{busy ? "Working…" : dirty ? "Save" : "Saved"}
</button>
</div>
</div>
</div>
{/* Build Photos */}
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">Build Photos</div>
<div className="text-xs opacity-70">
Select photos, remove any you dont want, then upload.
</div>
</div>
<div className="flex items-center gap-2">
{/* Select (no upload) */}
<label
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
uploading ? "opacity-60 pointer-events-none" : ""
}`}
title={uploading ? "Uploading…" : "Select photos"}
>
Select Photos
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []).filter(
isImage
);
if (!files.length) return;
const local: LocalPreview[] = files.map((f) => ({
id:
typeof crypto !== "undefined" &&
"randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random()}`,
file: f,
url: URL.createObjectURL(f),
}));
setPreviews((prev) => [...local, ...prev]);
e.target.value = ""; // reset input so re-selecting same file works
}}
/>
</label>
{/* Upload (real upload) */}
<button
type="button"
disabled={uploading || previews.length === 0 || !build?.uuid}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
onClick={async () => {
if (!build?.uuid || previews.length === 0) return;
setError(null);
setUploading(true);
// snapshot so user can keep selecting more while current batch uploads
const batch = [...previews];
try {
for (const p of batch) {
// if user removed it before we got to it, skip
const stillThere = previews.some((x) => x.id === p.id);
if (!stillThere) continue;
const created = await fileToUpload(p.file, build.uuid);
setBuild((prev) =>
prev
? {
...prev,
photos: [created, ...(prev.photos ?? [])],
}
: prev
);
// remove preview after success
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}
} catch (err: any) {
setError(err?.message ?? "Photo upload failed");
} finally {
setUploading(false);
}
}}
>
{uploading
? "Uploading…"
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
</button>
</div>
</div>
{/* Gallery */}
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{/* Local previews (pending upload) */}
{previews.map((p) => (
<div
key={p.id}
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
{/* remove X */}
<button
type="button"
aria-label="Remove"
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
onClick={() => {
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}}
disabled={uploading}
title={
uploading
? "Wait for upload to finish"
: "Remove from upload list"
}
>
</button>
<img
src={p.url}
alt="Pending upload"
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80">
{uploading ? "Uploading…" : "Pending upload"}
</div>
</div>
))}
{/* Uploaded photos */}
{(build?.photos ?? []).map((p) => (
<div
key={p.uuid}
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
<img
src={p.url}
alt={p.caption ?? "Build photo"}
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80 line-clamp-2">
{p.caption ?? "—"}
</div>
</div>
))}
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
<div className="col-span-full text-xs opacity-70">
No photos yet. Select a few and upload when ready.
</div>
)}
</div>
</div>
{/* danger zone */}
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
<div className="text-sm font-semibold text-red-200">
Danger zone
</div>
<div className="mt-1 text-xs text-red-200/70">
Deleting a build is permanent.
</div>
<button
onClick={onDelete}
disabled={busy}
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-100 hover:bg-red-500/15 disabled:opacity-50"
>
Delete build
</button>
</div>
</div>
)}
</main>
);
}
+255
View File
@@ -0,0 +1,255 @@
// app/vault/page.tsx
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = {
id: string; // internal numeric id (not used by UI)
uuid: string; // public identifier
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// UUID validator (defensive)
const isUuid = (v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
v
);
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d;
return dt.toLocaleString();
}
export default function VaultPage() {
const router = useRouter();
const api = useApi();
// NOTE:
// - `loading` here is AuthContext hydration, not network.
// - `token` is the real gate for /me endpoints.
const { user, token, loading: authLoading } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const authed = !!user && !!token;
// Redirect if not logged in (after auth hydrates)
useEffect(() => {
if (!authLoading && !authed) {
router.replace("/login");
}
}, [authLoading, authed, router]);
// When auth identity changes, clear stale list to avoid “ghost builds”
useEffect(() => {
if (authLoading) return;
setBuilds([]);
setError(null);
}, [authLoading, user?.uuid, token]);
// Fetch user builds
useEffect(() => {
if (authLoading || !authed) return;
let cancelled = false;
(async () => {
setBusy(true);
setError(null);
try {
// IMPORTANT:
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
const res = await api.get("/api/v1/builds/me");
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const data = (await res.json()) as BuildDto[];
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds");
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [authLoading, authed, api]);
if (authLoading) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
}
// Already redirected
if (!authed) return null;
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Vault</h1>
<p className="text-sm opacity-70">
Your saved builds. Open one to keep tweaking parts, or add details
to submit it to the community.
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/builder"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
title="Create a new build in the builder"
>
New Build
</Link>
<Link href="/account" className="text-sm opacity-80 hover:underline" title={"Your account info"}>
Account
</Link>
</div>
</div>
{/* Error */}
{error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error}
</div>
)}
{/* Builds list */}
<div className="rounded-xl border border-white/10 bg-white/5">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="text-sm font-medium">My Builds</div>
<div className="text-xs opacity-70">
{busy ? "Refreshing…" : `${builds.length} total`}
</div>
</div>
{builds.length === 0 ? (
<div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save As
</div>
) : (
<ul className="divide-y divide-white/10">
{builds.map((b) => {
const valid = !!b?.uuid && isUuid(String(b.uuid));
const published = Boolean(b.isPublic);
const openHref = valid
? `/builder?load=${encodeURIComponent(b.uuid)}`
: "#";
const detailsHref = valid
? `/vault/${encodeURIComponent(b.uuid)}/edit`
: "#";
return (
<li
key={b.uuid}
className="p-4 flex items-start justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{b.title}</div>
{/* Status chip */}
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${
published
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
title={
published
? "This build is public (community-visible)."
: "Draft (only visible in your Vault)."
}
>
{published ? "Published" : "Draft"}
</span>
{!valid && (
<span
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
title="This build record has an invalid UUID."
>
Invalid UUID
</span>
)}
</div>
<div className="mt-1 text-xs opacity-70">
Updated: {fmt(b.updatedAt)} UUID:{" "}
<span className="font-mono">{b.uuid}</span>
</div>
{b.description && (
<div className="mt-2 text-sm opacity-80 line-clamp-2">
{b.description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href={openHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Load this build into the builder" : "Invalid build id"}
>
View in Builder
</Link>
<Link
href={detailsHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Edit details / publish later" : "Invalid build id"}
>
Edit Title
</Link>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
export type SendEmailPayload = {
recipient: string;
subject: string;
body: string;
};
export type ApiResponse<T> = {
success?: boolean;
message?: string;
data?: T;
};
export type EmailRequest = {
id: number;
recipient: string;
subject: string;
body: string;
sentAt: string | null;
status: "PENDING" | "SENT" | "FAILED" | string;
errorMessage: string | null;
createdAt: string;
};
export async function sendEmailAction(
payload: SendEmailPayload
): Promise<ApiResponse<EmailRequest>> {
const baseUrl = process.env.BACKEND_BASE_URL;
if (!baseUrl) {
throw new Error("BACKEND_BASE_URL is not set");
}
const res = await fetch(`${baseUrl}/api/email/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Authorization: `Bearer ${process.env.BACKEND_JWT}`, // if needed
},
body: JSON.stringify(payload),
cache: "no-store",
});
const data: ApiResponse<EmailRequest> | null = await res
.json()
.catch(() => null);
if (!res.ok) {
throw new Error(data?.message ?? `Request failed with ${res.status}`);
}
if (!data) {
throw new Error("Empty response body");
}
return data;
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import { Button, Field, Input } from "@/components/ui/form";
export default function AdminBetaInvitesPage() {
const [dryRun, setDryRun] = useState(true);
const [limit, setLimit] = useState("1");
const [tokenMinutes, setTokenMinutes] = useState("30");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
async function runInvites() {
setLoading(true);
setError(null);
setResult(null);
try {
const qs = new URLSearchParams({
dryRun: String(dryRun),
limit: String(limit || "0"),
tokenMinutes: String(tokenMinutes || "30"),
});
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
setResult(data);
} catch (e: any) {
setError(e?.message ?? "Failed");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender (uses your email templates + tracking).
</p>
</div>
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
Dry run (do everything except actually send)
</label>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Limit" htmlFor="limit">
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
</Field>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
/>
</Field>
</div>
<Button onClick={runInvites} disabled={loading}>
{loading ? "Running…" : "Send Beta Invites"}
</Button>
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{result && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchEmailRequests,
createEmailRequest,
updateEmailRequest,
deleteEmailRequest,
type EmailRequest,
type CreateEmailRequestDto,
type UpdateEmailRequestDto,
} from "@/lib/api/emailRequests";
import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/dateFormattingUtility";
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
const [formData, setFormData] = useState<CreateEmailRequestDto>({
email: "",
status: "",
});
const [submitting, setSubmitting] = useState(false);
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
const loadEmails = async () => {
if (!token) return;
try {
setLoading(true);
const data = await fetchEmailRequests(token);
setEmails(data.data);
setError(null);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load email requests");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadEmails();
}, [token]);
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
const counts = useMemo(() => {
const c = { ALL: 0, PENDING: 0, SENT: 0, FAILED: 0, OTHER: 0 } as Record<
EmailStatusTab,
number
>;
c.ALL = emails.length;
for (const e of emails) {
const st = normalizeStatus(e.status);
if (st === "PENDING") c.PENDING += 1;
else if (st === "SENT") c.SENT += 1;
else if (st === "FAILED") c.FAILED += 1;
else c.OTHER += 1;
}
return c;
}, [emails]);
const filteredEmails = useMemo(() => {
if (statusTab === "ALL") return emails;
return emails.filter((e) => {
const st = normalizeStatus(e.status);
if (statusTab === "OTHER") {
return st !== "PENDING" && st !== "SENT" && st !== "FAILED";
}
return st === statusTab;
});
}, [emails, statusTab]);
const tabs: { key: EmailStatusTab; label: string }[] = [
{ key: "ALL", label: "All" },
{ key: "PENDING", label: "Pending" },
{ key: "SENT", label: "Sent" },
{ key: "FAILED", label: "Failed" },
{ key: "OTHER", label: "Other" },
];
const handleCreate = () => {
setModalMode("create");
setFormData({ email: "", status: "" });
setSelectedEmail(null);
setShowModal(true);
};
const handleEdit = (email: EmailRequest) => {
setModalMode("edit");
setFormData({ email: email.email, status: email.status });
setSelectedEmail(email);
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return;
try {
await deleteEmailRequest(token, id);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to delete email request");
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
try {
setSubmitting(true);
if (modalMode === "create") {
await createEmailRequest(token, formData);
} else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData);
}
setShowModal(false);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to save email request");
} finally {
setSubmitting(false);
}
};
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return (
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">Emails</h1>
<p className="mt-1 text-xs text-zinc-400">
Manage Email and their statuses.
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
>
<Plus size={14} />
Add Email
</button>
</div>
{/* Status Tabs */}
<div className="flex flex-wrap items-center gap-2">
{tabs.map((t) => {
const active = statusTab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setStatusTab(t.key)}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs transition ${
active
? "border-amber-500/60 bg-amber-500/10 text-amber-200"
: "border-zinc-800 bg-zinc-950/60 text-zinc-300 hover:border-amber-400/60 hover:text-amber-300"
}`}
>
<span>{t.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] ${
active ? "bg-amber-500/15 text-amber-200" : "bg-zinc-900 text-zinc-400"
}`}
>
{counts[t.key]}
</span>
</button>
);
})}
</div>
{/* Error */}
{error && (
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{/* Table */}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-900/80 text-zinc-400">
<tr>
<th className="px-3 py-2 font-medium">ID</th>
<th className="px-3 py-2 font-medium">Email</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Created At</th>
<th className="px-3 py-2 font-medium">Updated At</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{filteredEmails.map((email) => (
<tr
key={email.id}
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
>
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
<td className="px-3 py-2">
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
{email.status}
</span>
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{new Date(email.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{email.updatedAt ? formatDate(email.updatedAt) : "—"}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(email)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(email.id)}
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{filteredEmails.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-4 text-center text-xs text-zinc-500"
>
No email requests found for{" "}
<span className="text-zinc-300">{tabs.find((t) => t.key === statusTab)?.label}</span>.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
</h2>
<button
onClick={() => setShowModal(false)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Status
</label>
<input
type="text"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="pending"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={submitting}
>
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchEmailRequests,
createEmailRequest,
updateEmailRequest,
deleteEmailRequest,
type EmailRequest,
type CreateEmailRequestDto,
type UpdateEmailRequestDto,
} from "@/lib/api/emailRequests";
import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/dateFormattingUtility";
export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
const [formData, setFormData] = useState<CreateEmailRequestDto>({
email: "",
status: "",
});
const [submitting, setSubmitting] = useState(false);
const loadEmails = async () => {
if (!token) return;
try {
setLoading(true);
const data = await fetchEmailRequests(token);
setEmails(data.data);
setError(null);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load email requests");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadEmails();
}, [token]);
const handleCreate = () => {
setModalMode("create");
setFormData({ email: "", status: "" });
setSelectedEmail(null);
setShowModal(true);
};
const handleEdit = (email: EmailRequest) => {
setModalMode("edit");
setFormData({ email: email.email, status: email.status });
setSelectedEmail(email);
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return;
try {
await deleteEmailRequest(token, id);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to delete email request");
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
try {
setSubmitting(true);
if (modalMode === "create") {
await createEmailRequest(token, formData);
} else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData);
}
setShowModal(false);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to save email request");
} finally {
setSubmitting(false);
}
};
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return (
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">
Processed E-mail
</h1>
<p className="mt-1 text-xs text-zinc-400">
Manage email requests and their statuses.
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
>
<Plus size={14} />
Add Email Request
</button>
</div>
{/* Error */}
{error && (
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{/* Table */}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-900/80 text-zinc-400">
<tr>
<th className="px-3 py-2 font-medium">ID</th>
<th className="px-3 py-2 font-medium">Email</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Created At</th>
<th className="px-3 py-2 font-medium">Updated At</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{emails.map((email) => (
<tr
key={email.id}
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
>
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
<td className="px-3 py-2">
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
{email.status}
</span>
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{new Date(email.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{formatDate(email.updatedAt)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(email)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(email.id)}
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{emails.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-4 text-center text-xs text-zinc-500"
>
No email requests found.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
</h2>
<button
onClick={() => setShowModal(false)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Status
</label>
<input
type="text"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="pending"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={submitting}
>
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form";
export default function SendEmailForm(): JSX.Element {
const [result, setResult] = useState<
ApiResponse<EmailRequest> | { error: string } | null
>(null);
const [submitting, setSubmitting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, []);
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setResult(null);
setShowSuccess(false);
if (successTimerRef.current) clearTimeout(successTimerRef.current);
const form = new FormData(e.currentTarget);
const recipient = String(form.get("recipient") ?? "");
const subject = String(form.get("subject") ?? "");
const body = String(form.get("body") ?? "");
try {
setSubmitting(true);
const data = await sendEmailAction({ recipient, subject, body });
setResult(data);
if (data?.success === true) {
setShowSuccess(true);
successTimerRef.current = setTimeout(() => {
setShowSuccess(false);
}, 2000);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setResult({ error: message });
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
<h1 className="text-2xl font-semibold tracking-tight">
Send <span className="text-amber-300">Email</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
Compose a message and send it from the admin panel.
</p>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
{result && "error" in result && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{result.error}
</div>
)}
<Field label="Recipient" htmlFor="recipient">
<Input
id="recipient"
name="recipient"
type="email"
placeholder="user@example.com"
defaultValue="user@example.com"
required
/>
</Field>
<Field label="Subject" htmlFor="subject">
<Input
id="subject"
name="subject"
placeholder="Test subject"
defaultValue="Test subject"
required
/>
</Field>
<Field label="Body" htmlFor="body">
<Textarea
id="body"
name="body"
placeholder="Write your message…"
defaultValue="Test body"
rows={6}
required
/>
</Field>
<Button type="submit" disabled={submitting}>
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
</Button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
<div className="text-xs font-medium text-zinc-400">Response</div>
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
{result ? JSON.stringify(result, null, 2) : "No response yet."}
</pre>
</div>
</form>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import SendEmailForm from "@/components/SendEmail";
export default function SendEmailPage() {
return <SendEmailForm />;
}
@@ -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>
);
}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const formatCount = (value: number | null | undefined) =>
(typeof value === "number" ? value : 0).toLocaleString();
type ImportSummary = {
totalProducts: number;
mappedProducts: number;
pendingProducts: number;
};
type RawByMerchantRow = {
merchantId: number;
merchantName: string;
platform: string;
status: string;
count: number;
};
type MerchantImportRow = {
merchantId: number;
merchantName: string;
totalProducts: number;
mappedProducts: number;
pendingProducts: number;
lastImportAt?: string | null;
};
export default function ImportStatusPage() {
const [summary, setSummary] = useState<ImportSummary | null>(null);
const [rows, setRows] = useState<MerchantImportRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [importingId, setImportingId] = useState<number | null>(null);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const [summaryRes, byMerchantRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
]);
if (!summaryRes.ok || !byMerchantRes.ok) {
throw new Error("Failed to load import status");
}
const summaryJson: ImportSummary = await summaryRes.json();
const byMerchantRaw: RawByMerchantRow[] = await byMerchantRes.json();
// Aggregate per merchant using merchantId as the key
const merchantMap = new Map<number, MerchantImportRow>();
for (const row of byMerchantRaw) {
const id = row.merchantId;
const name = row.merchantName;
const status = row.status;
const count = row.count ?? 0;
if (!merchantMap.has(id)) {
merchantMap.set(id, {
merchantId: id,
merchantName: name,
totalProducts: 0,
mappedProducts: 0,
pendingProducts: 0,
lastImportAt: null, // not provided by API yet
});
}
const agg = merchantMap.get(id)!;
agg.totalProducts += count;
if (status === "MAPPED") {
agg.mappedProducts += count;
} else if (status === "PENDING_MAPPING") {
agg.pendingProducts += count;
}
}
const byMerchantJson: MerchantImportRow[] = Array.from(
merchantMap.values()
);
setSummary(summaryJson);
setRows(byMerchantJson);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load import status");
} finally {
setLoading(false);
}
}
load();
}, []);
async function handleReimport(merchantId: number) {
try {
setImportingId(merchantId);
const res = await fetch(
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
{
method: "POST",
}
);
if (!res.ok) {
throw new Error(`Re-import failed (${res.status})`);
}
// Optionally re-load data here if you want the table to refresh automatically
} catch (e: any) {
console.error(e);
alert(e.message ?? "Failed to trigger import");
} finally {
setImportingId(null);
}
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-8">
<header className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Import Status
</h1>
<p className="mt-1 text-sm text-zinc-400">
Per-merchant feed health and mapping coverage.
</p>
</div>
<Link
href="/admin"
className="text-xs text-zinc-400 hover:text-zinc-200"
>
Back to Admin Home
</Link>
</header>
{loading && (
<p className="mt-6 text-sm text-zinc-400">Loading import status</p>
)}
{error && <p className="mt-6 text-sm text-red-400">Error: {error}</p>}
{summary && !loading && !error && (
<>
{/* Summary cards */}
<section className="mt-6 grid gap-4 md:grid-cols-3">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Total Products
</p>
<p className="mt-2 text-xl font-semibold">
{formatCount(summary.totalProducts)}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Mapped
</p>
<p className="mt-2 text-xl font-semibold text-emerald-400">
{formatCount(summary.mappedProducts)}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Pending Mapping
</p>
<p className="mt-2 text-xl font-semibold text-amber-300">
{formatCount(summary.pendingProducts)}
</p>
</div>
</section>
<section className="mt-8">
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
By Merchant
</h2>
<div className="mt-3 overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/70">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-800 bg-zinc-950/80 text-xs uppercase text-zinc-500">
<tr>
<th className="px-4 py-3">Merchant</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3 text-right">Mapped</th>
<th className="px-4 py-3 text-right">Pending</th>
<th className="px-4 py-3">Last Import</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.merchantId}
className="border-t border-zinc-900/80"
>
<td className="px-4 py-3">
<span className="font-medium text-zinc-50">
{row.merchantName}
</span>
</td>
<td className="px-4 py-3 text-right text-zinc-300">
{formatCount(row.totalProducts)}
</td>
<td className="px-4 py-3 text-right text-emerald-400">
{formatCount(row.mappedProducts)}
</td>
<td className="px-4 py-3 text-right text-amber-300">
{formatCount(row.pendingProducts)}
</td>
<td className="px-4 py-3 text-xs text-zinc-500">
{row.lastImportAt
? new Date(row.lastImportAt).toLocaleString()
: "—"}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Link
href={`/admin/mapping?merchantId=${row.merchantId}`}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-200 hover:border-amber-400/80 hover:bg-zinc-800"
>
Mapping
</Link>
<button
type="button"
onClick={() => handleReimport(row.merchantId)}
disabled={importingId === row.merchantId}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-200 hover:border-amber-400/80 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{importingId === row.merchantId
? "Re-importing…"
: "Re-import"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
</main>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import type React 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,
Layers,
Boxes,
Store,
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: "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" /> },
];
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 */}
<div className="flex min-h-screen flex-1 flex-col">
{/* Top bar */}
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Admin
</p>
<p className="text-sm text-zinc-200">
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
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
ADMIN
</div>
</div>
</header>
{/* Content */}
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
{children}
</main>
</div>
</div>
);
}
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useEffect, useMemo, useState } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type PendingBucket = {
merchantId: number;
merchantName: string;
rawCategoryKey: string;
mappedPartRole: string | null;
productCount: number;
};
/**
* Canonical part roles (kebab-case) — should match what the importer normalizes to.
* Keep this list tight + intentional so mappings don't create junk roles in the DB.
*/
const PART_ROLE_OPTIONS = [
// Assemblies / receivers
"upper-receiver",
"complete-upper",
"lower-receiver",
"complete-lower",
// Upper sub-parts
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
// Lower sub-parts
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
// Sights / optics
"sights",
"optic",
// Accessories / gear
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
] as const;
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
function toLabel(role: string) {
// "complete-upper" -> "Complete Upper"
return role
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export default function MappingAdminPage() {
const [rows, setRows] = useState<PendingBucket[]>([]);
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const options = useMemo(
() =>
PART_ROLE_OPTIONS.map((value) => ({
value,
label: toLabel(value),
})),
[]
);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/pending-buckets`,
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}`
);
}
const data: PendingBucket[] = await res.json();
setRows(data);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load pending buckets");
} finally {
setLoading(false);
}
}
load();
}, []);
const handleChangeRole = (idx: number, value: string) => {
// Always store normalized kebab-case (defensive)
const normalized = value
? value.trim().toLowerCase().replace(/_/g, "-")
: "";
setRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
return next;
});
};
const handleSave = async (row: PendingBucket) => {
if (!row.mappedPartRole) return;
const mapped = row.mappedPartRole
.trim()
.toLowerCase()
.replace(/_/g, "-");
// Basic guard: prevent saving random roles from stale data
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
if (!allowed.has(mapped)) {
setError(
`Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.`
);
return;
}
const key = `${row.merchantId}-${row.rawCategoryKey}`;
try {
setSavingKey(key);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey,
mappedPartRole: mapped,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to save mapping (${res.status})${text ? `: ${text}` : ""}`
);
}
// After save, remove this bucket from the list
setRows((prev) =>
prev.filter(
(r) =>
!(
r.merchantId === row.merchantId &&
r.rawCategoryKey === row.rawCategoryKey
)
)
);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to save mapping");
} finally {
setSavingKey(null);
}
};
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="text-xl font-semibold tracking-tight">
Bulk Mapping Pending Categories
</h1>
<p className="mt-1 text-sm text-zinc-400">
Bucketed by merchant + raw category. Map each bucket to a part role to
clean up the builder catalog.
</p>
{loading && (
<p className="mt-4 text-sm text-zinc-400">Loading pending buckets</p>
)}
{error && (
<p className="mt-4 text-sm text-red-400">Error: {error}</p>
)}
{!loading && rows.length === 0 && !error && (
<p className="mt-4 text-sm text-emerald-400">
No pending mapping buckets 🎉
</p>
)}
{!loading && rows.length > 0 && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Pending Buckets
</h2>
<span className="text-xs text-zinc-400">
{rows.length} buckets {" "}
{rows.reduce((s, r) => s + r.productCount, 0)} products
</span>
</div>
<div className="mt-1 overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-2 py-1 text-left text-zinc-400">
Merchant
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Raw Category
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Products
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Part Role
</th>
<th className="px-2 py-1 text-right text-zinc-400">
Action
</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => {
const rowKey = `${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey;
return (
<tr
key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40"
>
<td className="px-2 py-1 text-zinc-100">
{row.merchantName}
</td>
<td className="px-2 py-1 text-zinc-300">
{row.rawCategoryKey}
</td>
<td className="px-2 py-1 text-zinc-200">
{row.productCount}
</td>
<td className="px-2 py-1 text-zinc-100">
<select
className="w-full 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"
value={row.mappedPartRole ?? ""}
onChange={(e) =>
handleChangeRole(idx, e.target.value)
}
>
<option value="">Select part role</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} ({opt.value})
</option>
))}
</select>
</td>
<td className="px-2 py-1 text-right">
<button
onClick={() => handleSave(row)}
disabled={!row.mappedPartRole || isSaving}
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
>
{isSaving ? "Saving…" : "Save"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div>
</main>
);
}
+51 -75
View File
@@ -34,52 +34,50 @@ export default function MerchantsAdminPage() {
// --- load merchants ---
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
async function loadMerchants() {
try {
console.log("Loading merchants from:", `${API_BASE_URL}/admin/merchants`);
const res = await fetch(`${API_BASE_URL}/admin/merchants`, {
setLoading(true);
setError(null);
const url = `${API_BASE_URL}/api/admin/merchants`;
console.log("Loading merchants from:", url);
const res = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
},
headers: { Accept: "application/json" },
signal: controller.signal,
});
console.log("Merchants response status:", res.status);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText} ${text}`);
}
const data: MerchantAdminDto[] = await res.json();
setMerchants(data);
setError(null);
} catch (err: any) {
if (err?.name === "AbortError") return;
console.error("Error loading merchants", err);
setError(err.message ?? "Failed to load merchants");
setError(err?.message ?? "Failed to load merchants");
} finally {
setLoading(false);
}
}
loadMerchants();
return () => {
cancelled = true;
};
loadMerchants();
return () => controller.abort();
}, []);
// --- local field editing ---
const updateMerchantField = <K extends keyof MerchantAdminDto>(
id: number,
field: K,
value: MerchantAdminDto[K],
value: MerchantAdminDto[K]
) => {
setMerchants((prev) =>
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)),
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m))
);
};
@@ -93,7 +91,7 @@ export default function MerchantsAdminPage() {
setError(null);
setBanner(null);
const res = await fetch(`${API_BASE_URL}/admin/merchants/${id}`, {
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -108,20 +106,16 @@ export default function MerchantsAdminPage() {
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Merchant save failed", res.status, text);
throw new Error(
`Save failed (${res.status})${text ? `: ${text}` : ""}`,
);
throw new Error(`Save failed (${res.status})${text ? `: ${text}` : ""}`);
}
const updated: MerchantAdminDto = await res.json();
setMerchants((prev) =>
prev.map((m) => (m.id === id ? updated : m)),
);
setMerchants((prev) => prev.map((m) => (m.id === id ? updated : m)));
setBanner("Merchant saved successfully.");
} catch (e: any) {
console.error("Error saving merchant", e);
setError(e.message ?? "Failed to save merchant");
setError(e?.message ?? "Failed to save merchant");
} finally {
setSavingId(null);
setTimeout(() => setBanner(null), 3000);
@@ -135,29 +129,27 @@ export default function MerchantsAdminPage() {
setError(null);
setBanner(null);
const res = await fetch(`${API_BASE_URL}/admin/imports/${id}`, {
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, {
method: "POST",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Full import failed", res.status, text);
throw new Error(
`Import failed (${res.status})${text ? `: ${text}` : ""}`,
);
throw new Error(`Import failed (${res.status})${text ? `: ${text}` : ""}`);
}
setBanner("Full import started successfully.");
} catch (e: any) {
console.error("Error starting full import", e);
setError(e.message ?? "Failed to start full import");
setError(e?.message ?? "Failed to start full import");
} finally {
setImportingId(null);
setTimeout(() => setBanner(null), 3000);
}
};
// --- run offer sync (matches /{merchantId}/offers-only) ---
// --- run offer sync ---
const handleRunOfferSync = async (id: number) => {
try {
setOfferSyncId(id);
@@ -165,24 +157,22 @@ export default function MerchantsAdminPage() {
setBanner(null);
const res = await fetch(
`${API_BASE_URL}/admin/imports/${id}/offers-only`,
{
method: "POST",
},
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
{ method: "POST" }
);
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Offer sync failed", res.status, text);
throw new Error(
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`,
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`
);
}
setBanner("Offer sync started successfully.");
} catch (e: any) {
console.error("Error starting offer sync", e);
setError(e.message ?? "Failed to start offer sync");
setError(e?.message ?? "Failed to start offer sync");
} finally {
setOfferSyncId(null);
setTimeout(() => setBanner(null), 3000);
@@ -202,9 +192,9 @@ export default function MerchantsAdminPage() {
Merchant Feeds <span className="text-amber-300">Admin</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Manage your AvantLink merchants, product feed URLs, and offer
sync settings. Use this to onboard new merchants and keep feeds
fresh without touching SQL.
Manage your AvantLink merchants, product feed URLs, and offer sync
settings. Use this to onboard new merchants and keep feeds fresh
without touching SQL.
</p>
</div>
</header>
@@ -244,6 +234,7 @@ export default function MerchantsAdminPage() {
<th className="py-2 pl-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{merchants.map((m) => (
<tr key={m.id} className="align-top">
@@ -257,71 +248,62 @@ export default function MerchantsAdminPage() {
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.avantlinkMid}
onChange={(e) =>
updateMerchantField(
m.id,
"avantlinkMid",
e.target.value,
)
updateMerchantField(m.id, "avantlinkMid", e.target.value)
}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.feedUrl ?? ""}
onChange={(e) =>
updateMerchantField(
m.id,
"feedUrl",
e.target.value,
)
updateMerchantField(m.id, "feedUrl", e.target.value)
}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.offerFeedUrl ?? ""}
onChange={(e) =>
updateMerchantField(
m.id,
"offerFeedUrl",
e.target.value || null,
)
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={m.isActive}
onChange={(e) =>
updateMerchantField(
m.id,
"isActive",
e.target.checked,
)
updateMerchantField(m.id, "isActive", e.target.checked)
}
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
/>
Active
</label>
</td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastFullImportAt)}
</td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastOfferSyncAt)}
</td>
<td className="py-2 pl-3">
<div className="flex flex-col items-end gap-1">
<button
@@ -336,33 +318,27 @@ export default function MerchantsAdminPage() {
>
{savingId === m.id ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleRunFullImport(m.id)}
disabled={importingId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
importingId === m.id
? "opacity-60 cursor-wait"
: ""
importingId === m.id ? "opacity-60 cursor-wait" : ""
}`}
>
{importingId === m.id
? "Importing…"
: "Run Full Import"}
{importingId === m.id ? "Importing…" : "Run Full Import"}
</button>
<button
type="button"
onClick={() => handleRunOfferSync(m.id)}
disabled={offerSyncId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
offerSyncId === m.id
? "opacity-60 cursor-wait"
: ""
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
}`}
>
{offerSyncId === m.id
? "Syncing Offers…"
: "Run Offer Sync"}
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"}
</button>
</div>
</td>
+166 -55
View File
@@ -1,67 +1,178 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export default function AdminHomePage() {
const { token } = useAuth();
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
type AdminOverview = {
totalProducts: number;
mappedProducts: number;
unmappedProducts: number;
merchantCount: number;
categoryMappingCount: number;
};
const cards = [
{
title: "Canonical categories",
href: "admin/categories",
description:
"Manage the core builder categories and their group/grouping + sort order.",
},
{
title: "Part role mappings",
href: "/admin/part-role-mappings",
description:
"Advanced: map logical builder part roles to canonical categories per platform.",
},
{
title: "Merchant category mappings",
href: "/admin/merchant-category-mappings",
description:
"Map raw merchant feed categories to your canonical categories.",
},
];
export default function AdminLandingPage() {
const [overview, setOverview] = useState<AdminOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`);
if (!res.ok) {
throw new Error(`Failed to load dashboard (${res.status})`);
}
const data: AdminOverview = await res.json();
setOverview(data);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load admin dashboard");
} finally {
setLoading(false);
}
}
load();
}, []);
return (
<div className="mx-auto max-w-5xl p-6 space-y-6">
<div>
<h1 className="text-lg font-semibold tracking-tight text-zinc-100">
Admin
</h1>
<p className="mt-1 text-xs text-zinc-400">
Internal tools to keep your builder data clean and consistent.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{cards.map((card) => (
<Link
key={card.href}
href={card.href}
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-left hover:border-emerald-500/70 hover:bg-zinc-900/80"
>
<h2 className="text-sm font-medium text-zinc-100 group-hover:text-white">
{card.title}
</h2>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
{card.description}
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-8">
<header className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Battl Builder Admin
</h1>
<p className="mt-1 text-sm text-zinc-400">
Control room for feeds, mappings, and catalog health.
</p>
</Link>
))}
</div>
</header>
{loading && (
<p className="mt-6 text-sm text-zinc-400">Loading dashboard</p>
)}
{error && (
<p className="mt-6 text-sm text-red-400">Error: {error}</p>
)}
{overview && !loading && !error && (
<>
{/* Stats row */}
<section className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Total Products
</p>
<p className="mt-2 text-2xl font-semibold">
{overview.totalProducts.toLocaleString()}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Mapped Products
</p>
<p className="mt-2 text-2xl font-semibold text-emerald-400">
{overview.mappedProducts.toLocaleString()}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Unmapped Products
</p>
<p className="mt-2 text-2xl font-semibold text-amber-400">
{overview.unmappedProducts.toLocaleString()}
</p>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Products still in <code>PENDING_MAPPING</code>.
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Merchants / Mappings
</p>
<p className="mt-2 text-lg font-semibold">
{overview.merchantCount}{" "}
<span className="text-xs font-normal text-zinc-400">
merchants
</span>
</p>
<p className="text-sm text-zinc-400">
{overview.categoryMappingCount.toLocaleString()} mappings
</p>
</div>
</section>
{/* Nav cards */}
<section className="mt-10">
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Admin Tools
</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Bulk Mapping */}
<Link
href="/admin/mapping"
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Bulk Category Mapping
</p>
<p className="mt-1 text-xs text-zinc-400">
Bucket raw merchant categories and map them to part roles to
keep the catalog clean.
</p>
<p className="mt-3 text-xs text-amber-300">
{overview.unmappedProducts.toLocaleString()} products
pending mapping
</p>
</Link>
{/* Import Status */}
<Link
href="/admin/import-status"
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Import Status
</p>
<p className="mt-1 text-xs text-zinc-400">
Inspect last feed runs, per-merchant health, and errors.
</p>
<p className="mt-3 text-xs text-zinc-500 group-hover:text-zinc-300">
View import summaries &nbsp;
</p>
</Link>
{/* Placeholder for future admin tools */}
<Link
href="/admin/products"
className="group rounded-lg border border-zinc-900 bg-zinc-950/40 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Catalog Explorer
</p>
<p className="mt-1 text-xs text-zinc-400">
(Future) Browse normalized products, offers, and compatibility.
</p>
<p className="mt-3 text-xs text-zinc-500 group-hover:text-zinc-300">
Coming soon
</p>
</Link>
</div>
</section>
</>
)}
</div>
</div>
</main>
);
}
+301
View File
@@ -0,0 +1,301 @@
// app/admin/platforms/page.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import { Plus, Pencil, Trash2, X } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import {
createPlatform,
deletePlatform,
fetchPlatforms,
updatePlatform,
type Platform,
type CreatePlatformDto,
type UpdatePlatformDto,
} from "@/lib/api/platforms";
export default function AdminPlatformsPage() {
const { getAuthHeaders } = useAuth();
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selected, setSelected] = useState<Platform | null>(null);
const [form, setForm] = useState<CreatePlatformDto>({
key: "",
label: "",
isActive: true,
});
async function load() {
try {
setLoading(true);
setError(null);
const data = await fetchPlatforms(getAuthHeaders());
setPlatforms(data);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load platforms");
setPlatforms([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sorted = useMemo(() => {
return [...platforms].sort((a, b) => a.key.localeCompare(b.key));
}, [platforms]);
function openCreate() {
setModalMode("create");
setSelected(null);
setForm({ key: "", label: "", isActive: true });
setShowModal(true);
}
function openEdit(p: Platform) {
setModalMode("edit");
setSelected(p);
setForm({ key: p.key, label: p.label, isActive: p.isActive });
setShowModal(true);
}
async function onDelete(p: Platform) {
if (!window.confirm(`Delete platform "${p.key}"? This cannot be undone.`)) return;
try {
setSaving(true);
setError(null);
await deletePlatform(getAuthHeaders(), p.id);
await load();
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to delete platform");
} finally {
setSaving(false);
}
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const key = (form.key ?? "").trim();
const label = (form.label ?? "").trim();
if (!key || !label) {
setError("Key and Label are required.");
return;
}
try {
setSaving(true);
setError(null);
if (modalMode === "create") {
await createPlatform(getAuthHeaders(), { ...form, key, label });
} else if (selected) {
const update: UpdatePlatformDto = {};
if (key !== selected.key) update.key = key;
if (label !== selected.label) update.label = label;
if ((form.isActive ?? true) !== selected.isActive) update.isActive = form.isActive;
await updatePlatform(getAuthHeaders(), selected.id, update);
}
setShowModal(false);
await load();
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to save platform");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-base font-semibold text-zinc-100">Platforms</h1>
<p className="mt-1 text-xs text-zinc-500">
Manage canonical platforms used across products and filtering.
</p>
</div>
<button
type="button"
onClick={openCreate}
className="inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={loading || saving}
>
<Plus size={14} />
Add Platform
</button>
</div>
{error && (
<div className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : sorted.length === 0 ? (
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-4 py-6 text-sm text-zinc-400">
No platforms found.
</div>
) : (
<div className="overflow-hidden rounded-md border border-zinc-900 bg-zinc-950">
<table className="min-w-full border-separate border-spacing-0 text-xs">
<thead className="bg-zinc-950/80">
<tr className="text-left text-[11px] uppercase tracking-[0.16em] text-zinc-500">
<th className="border-b border-zinc-900 px-3 py-2">ID</th>
<th className="border-b border-zinc-900 px-3 py-2">Key</th>
<th className="border-b border-zinc-900 px-3 py-2">Label</th>
<th className="border-b border-zinc-900 px-3 py-2">Active</th>
<th className="border-b border-zinc-900 px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((p, idx) => (
<tr
key={p.id}
className={idx % 2 === 0 ? "bg-zinc-950" : "bg-zinc-950/60"}
>
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-400">
{p.id}
</td>
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-100">
{p.key}
</td>
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-300">
{p.label}
</td>
<td className="border-b border-zinc-900 px-3 py-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
p.isActive
? "border border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
: "border border-zinc-700 bg-zinc-900 text-zinc-400"
}`}
>
{p.isActive ? "ACTIVE" : "INACTIVE"}
</span>
</td>
<td className="border-b border-zinc-900 px-3 py-2 text-right">
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => openEdit(p)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
title="Edit"
disabled={saving}
>
<Pencil size={14} />
</button>
<button
type="button"
onClick={() => void onDelete(p)}
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
title="Delete"
disabled={saving}
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">
{modalMode === "create" ? "Create Platform" : "Edit Platform"}
</h2>
<button
type="button"
onClick={() => setShowModal(false)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
<X size={16} />
</button>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Key
</label>
<input
value={form.key}
onChange={(e) => setForm((s) => ({ ...s, key: e.target.value }))}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
placeholder="AR15"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Label
</label>
<input
value={form.label}
onChange={(e) => setForm((s) => ({ ...s, label: e.target.value }))}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
placeholder="AR-15"
required
/>
</div>
<label className="flex items-center gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={!!form.isActive}
onChange={(e) => setForm((s) => ({ ...s, isActive: e.target.checked }))}
className="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-amber-400"
/>
Active
</label>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
disabled={saving}
>
Cancel
</button>
<button
type="submit"
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={saving}
>
{saving ? "Saving…" : modalMode === "create" ? "Create" : "Update"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+411
View File
@@ -0,0 +1,411 @@
"use client";
import { useEffect, useMemo, useState } from "react";
type ProductSummary = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
buyUrl: string | null;
imageUrl: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type PlatformOption = {
key: string; // e.g. AR15, AR9
label: string; // e.g. "Armalite Rifle model 15"
};
// Backend currently filters products by canonical platform strings like "AR-15".
// DB platform keys are like "AR15" / "AR9". Translate when calling /api/v1/products.
const platformKeyToApiPlatform = (key: string) => {
const k = (key ?? "").toUpperCase().trim();
if (!k) return "AR-15";
if (k === "AR15" || k === "AR-15") return "AR-15";
if (k === "AR10" || k === "AR-10") return "AR-10";
if (k === "AR9" || k === "AR-9") return "AR-9";
// Fallback: if it already contains a dash, assume it's canonical.
if (k.includes("-")) return k;
// Otherwise, best-effort: insert a dash after AR.
if (k.startsWith("AR") && k.length > 2) return `AR-${k.slice(2)}`;
return k;
};
const FALLBACK_PLATFORMS: PlatformOption[] = [
{ key: "AR15", label: "AR-15" },
{ key: "AR10", label: "AR-10" },
{ key: "AR9", label: "AR-9" },
];
export default function AdminProductsPage() {
const [platforms, setPlatforms] = useState<PlatformOption[]>(FALLBACK_PLATFORMS);
const [platform, setPlatform] = useState<string>("AR15");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [products, setProducts] = useState<ProductSummary[]>([]);
const [query, setQuery] = useState<string>("");
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(50);
// Load available platforms (dynamic if backend supports it; otherwise fallback)
useEffect(() => {
const controller = new AbortController();
async function loadPlatforms() {
try {
const res = await fetch(`${API_BASE_URL}/api/platforms`, {
signal: controller.signal,
});
if (!res.ok) return;
const data = (await res.json()) as unknown;
// Expected shape:
// [{ id, key, label, is_active, ... }]
const items = Array.isArray(data) ? data : [];
const cleaned: PlatformOption[] = items
.map((item) => {
if (!item || typeof item !== "object") return null;
const obj = item as Record<string, unknown>;
const key = typeof obj.key === "string" ? obj.key.trim() : "";
const label = typeof obj.label === "string" ? obj.label.trim() : "";
// Some payloads might use isActive; yours currently uses is_active
const activeRaw = obj.is_active ?? obj.isActive;
const isActive =
typeof activeRaw === "boolean"
? activeRaw
: typeof activeRaw === "number"
? activeRaw === 1
: true;
if (!key || !label || !isActive) return null;
return { key, label } as PlatformOption;
})
.filter((v): v is PlatformOption => v !== null);
if (cleaned.length === 0) return;
// Keep them stable and unique by key
const byKey = new Map<string, PlatformOption>();
for (const p of cleaned) byKey.set(p.key, p);
const unique = Array.from(byKey.values());
setPlatforms(unique);
setPlatform((prev) => (unique.some((p) => p.key === prev) ? prev : unique[0].key));
} catch (err: any) {
if (err?.name === "AbortError") return;
// ignore (fallback list stays)
}
}
loadPlatforms();
return () => controller.abort();
}, []);
// Load products for selected platform
useEffect(() => {
const controller = new AbortController();
async function loadProducts() {
try {
setLoading(true);
setError(null);
const apiPlatform = platformKeyToApiPlatform(platform);
// NOTE: backend endpoint expects `platform`, not `platformKey`.
const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
apiPlatform
)}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data = (await res.json()) as ProductSummary[];
setProducts(Array.isArray(data) ? data : []);
setPage(1);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
setProducts([]);
} finally {
setLoading(false);
}
}
loadProducts();
return () => controller.abort();
}, [platform]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return products;
return products.filter((p) => {
const name = (p.name ?? "").toLowerCase();
const brand = (p.brand ?? "").toLowerCase();
const role = (p.partRole ?? "").toLowerCase();
return name.includes(q) || brand.includes(q) || role.includes(q);
});
}, [products, query]);
// Reset to page 1 when the search query changes
useEffect(() => {
setPage(1);
}, [query, platform, pageSize]);
const totalPages = useMemo(() => {
const total = filtered.length;
return total === 0 ? 1 : Math.ceil(total / pageSize);
}, [filtered.length, pageSize]);
const clampedPage = useMemo(() => {
if (page < 1) return 1;
if (page > totalPages) return totalPages;
return page;
}, [page, totalPages]);
const pageStart = useMemo(() => (clampedPage - 1) * pageSize, [clampedPage, pageSize]);
const pageEndExclusive = useMemo(
() => Math.min(pageStart + pageSize, filtered.length),
[pageStart, pageSize, filtered.length]
);
const paginated = useMemo(() => {
return filtered.slice(pageStart, pageEndExclusive);
}, [filtered, pageStart, pageEndExclusive]);
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">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Admin
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Products <span className="text-amber-300">Catalog</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Read-only view of products coming from the Ballistic backend. Filter by
platform, search, and spot-check pricing + buy links.
</p>
</header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md: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-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Platform
<select
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{platforms.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2">
<label htmlFor="q" className="text-xs text-zinc-500">
Search
</label>
<input
id="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="brand, name, part role…"
className="w-64 max-w-full 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"
/>
</div>
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Page size
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
<div className="text-xs text-zinc-500">
{loading ? (
<span>Loading</span>
) : error ? (
<span className="text-red-400">{error}</span>
) : (
<span>
Showing{" "}
<span className="font-medium text-zinc-200">
{filtered.length === 0 ? 0 : pageStart + 1}-{pageEndExclusive}
</span>
{" "}of{" "}
<span className="font-medium text-zinc-200">{filtered.length}</span>
{" "}{filtered.length === 1 ? "product" : "products"}
</span>
)}
</div>
</div>
{!loading && !error && filtered.length > 0 && (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-[0.7rem] text-zinc-500">
Page <span className="font-semibold text-zinc-200">{clampedPage}</span> of{" "}
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={clampedPage === 1}
className="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 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={clampedPage === totalPages}
className="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 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
<div className="mt-4 overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Product</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Brand</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Platform</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Part Role</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Price</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Link</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
Loading products
</td>
</tr>
) : error ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-red-400" colSpan={6}>
{error}
</td>
</tr>
) : paginated.length === 0 ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
No products found.
</td>
</tr>
) : (
paginated.map((p) => (
<tr
key={p.id}
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
>
<td className="px-3 py-2 align-top">
<div className="font-medium text-zinc-100 line-clamp-2">{p.name}</div>
<div className="mt-0.5 text-[0.7rem] text-zinc-600">ID: {p.id}</div>
</td>
<td className="px-3 py-2 align-top text-zinc-300">{p.brand}</td>
<td className="px-3 py-2 align-top text-zinc-300">
{p.platform ?? platformKeyToApiPlatform(platform)}
</td>
<td className="px-3 py-2 align-top text-zinc-300">{p.partRole}</td>
<td className="px-3 py-2 align-top text-right text-amber-300">
{p.price == null ? "—" : `$${Number(p.price).toFixed(2)}`}
</td>
<td className="px-3 py-2 align-top text-right">
{p.buyUrl ? (
<a
href={p.buyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md bg-amber-400 px-2.5 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
>
Open
</a>
) : (
<span className="text-zinc-600"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{!loading && !error && filtered.length > 0 && (
<div className="mt-3 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={clampedPage === 1}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
aria-label="Previous page"
>
&lt;
</button>
<span className="text-xs text-zinc-500">
Page{" "}
<span className="font-semibold text-zinc-200">
{clampedPage}
</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={clampedPage === totalPages}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
aria-label="Next page"
>
&gt;
</button>
</div>
)}
<div className="mt-3 text-[0.7rem] text-zinc-500">
Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/v1/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
</div>
</section>
</div>
</main>
);
}
+34
View File
@@ -0,0 +1,34 @@
// app/admin/settings/page.tsx
"use client";
import { Settings } from "lucide-react";
export default function Page() {
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-base font-semibold text-zinc-100">Settings</h1>
<p className="mt-1 text-xs text-zinc-500">
Admin configuration and environment controls.
</p>
</div>
</div>
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-6 py-10">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 text-zinc-300">
<Settings className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-zinc-100">Coming soon</p>
<p className="mt-1 text-xs text-zinc-500">
This section is under construction. Check back after the next deploy.
</p>
</div>
</div>
</div>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext"; // adjust path if needed
import { Loader2 } from "lucide-react";
import type { AdminUser, UserRole } from "@/types/user";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export default function AdminUsersPage() {
const { getAuthHeaders } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function fetchUsers() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/admin/users`, {
headers: {
...getAuthHeaders(),
},
});
if (!res.ok) {
throw new Error(`Failed to load users (${res.status})`);
}
const data: AdminUser[] = await res.json();
setUsers(data);
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to load users");
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleRoleChange(userId: string, newRole: UserRole) {
setSavingId(userId);
setError(null);
const prev = users;
setUsers((cur) =>
cur.map((u) => (u.id === userId ? { ...u, role: newRole } : u))
);
try {
const res = await fetch(`${API_BASE_URL}/admin/users/${userId}/role`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
body: JSON.stringify({ role: newRole }),
});
if (!res.ok) {
throw new Error(`Failed to update role (${res.status})`);
}
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to update role");
// rollback
setUsers(prev);
} finally {
setSavingId(null);
}
}
function handleRoleSelectChange(user: AdminUser, newRole: UserRole) {
if (newRole === user.role) {
return;
}
// Confirm promotions to ADMIN
if (
newRole === "ADMIN" &&
!window.confirm(
`Promote ${user.email} to ADMIN? This will give them access to the admin console.`
)
) {
return;
}
// Confirm demotions from ADMIN
if (
user.role === "ADMIN" &&
newRole === "USER" &&
!window.confirm(
`Remove ADMIN access from ${user.email}? They will lose access to the admin console.`
)
) {
return;
}
void handleRoleChange(user.id, newRole);
}
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-base font-semibold text-zinc-100">
Users & Roles
</h1>
<p className="mt-1 text-xs text-zinc-500">
Promote trusted accounts to admins. All new signups start as{" "}
<span className="font-medium text-zinc-300">USER</span>.
</p>
</div>
<button
type="button"
onClick={fetchUsers}
className="inline-flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-amber-400/60 hover:text-amber-300"
disabled={loading}
>
{loading && (
<Loader2 className="h-3 w-3 animate-spin text-zinc-500" />
)}
<span>Refresh</span>
</button>
</div>
{error && (
<div className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{loading ? (
<div className="flex flex-1 items-center justify-center text-xs text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-zinc-500" />
Loading users
</div>
) : users.length === 0 ? (
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-4 py-6 text-sm text-zinc-400">
No users found yet. Once people start signing up, they&apos;ll show up
here.
</div>
) : (
<div className="overflow-hidden rounded-md border border-zinc-900 bg-zinc-950">
<table className="min-w-full border-separate border-spacing-0 text-xs">
<thead className="bg-zinc-950/80">
<tr className="text-left text-[11px] uppercase tracking-[0.16em] text-zinc-500">
<th className="border-b border-zinc-900 px-3 py-2">Email</th>
<th className="border-b border-zinc-900 px-3 py-2">
Display Name
</th>
<th className="border-b border-zinc-900 px-3 py-2">Role</th>
<th className="border-b border-zinc-900 px-3 py-2">Created</th>
<th className="border-b border-zinc-900 px-3 py-2">
Last Login
</th>
</tr>
</thead>
<tbody>
{users.map((u, idx) => (
<tr
key={u.id}
className={idx % 2 === 0 ? "bg-zinc-950" : "bg-zinc-950/60"}
>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-100">
{u.email}
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-300">
{u.displayName || "—"}
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle">
<div className="inline-flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
u.role === "ADMIN"
? "border border-amber-500/60 bg-amber-500/10 text-amber-200"
: "border border-zinc-700 bg-zinc-900 text-zinc-300"
}`}
>
{u.role}
</span>
<select
value={u.role}
onChange={(e) =>
handleRoleSelectChange(u, e.target.value as UserRole)
}
disabled={savingId === u.id}
className="rounded-md border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 outline-none transition hover:border-amber-400/60 focus:border-amber-400/80"
>
<option value="USER">USER</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-400">
{u.createdAt
? new Date(u.createdAt).toLocaleDateString()
: "—"}
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-400">
{u.lastLoginAt
? new Date(u.lastLoginAt).toLocaleDateString()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="mt-2 text-[10px] text-zinc-600">
Note: Role changes take effect on the user&apos;s next request. All new
accounts are created as <span className="text-zinc-300">USER</span> by
default.
</p>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) {
const token = cookies().get("bb_access_token")?.value;
if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
}
const url = new URL(req.url);
const dryRun = url.searchParams.get("dryRun") ?? "true";
const limit = url.searchParams.get("limit") ?? "0";
const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
const res = await fetch(upstream, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
});
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { token, role } = await req.json();
if (!token) {
return NextResponse.json({ ok: false }, { status: 400 });
}
const res = NextResponse.json({ ok: true });
// Server-readable, JS-unreadable
res.cookies.set("bb_access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
// Optional: for quick middleware role checks (not “secure” by itself)
if (role) {
res.cookies.set("bb_role", role, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}
return res;
}
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.set("bb_access_token", "", { path: "/", maxAge: 0 });
res.cookies.set("bb_role", "", { path: "/", maxAge: 0 });
return res;
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export async function POST(req: Request) {
try {
const body = await req.json();
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
// Always return ok=true (matches your server behavior)
if (!res.ok) {
// 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 (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>
);
}
-436
View File
@@ -1,436 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId } from "@/types/gunbuilder";
import {
getRetailerOffers,
type RetailerOffer,
} from "./data";
type GunbuilderProductFromApi = {
id: string; // backend UUID string
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
buyUrl: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export default function PartDetailPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const partId = params.partId as string; // this is the UUID we passed in the link
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [offers, setOffers] = useState<RetailerOffer[]>([]);
const [offersLoading, setOffersLoading] = useState(true);
const [offersError, setOffersError] = useState<string | null>(null);
const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId],
);
// 1) Load product (same as before)
useEffect(() => {
if (!partId) return;
const controller = new AbortController();
async function fetchProduct() {
try {
setLoading(true);
setError(null);
// For now, pull the full AR-15 list and find by UUID.
// We can optimize with a dedicated /api/products/{uuid} later.
const url = `${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const found = data.find((p) => p.id === partId) ?? null;
if (!found) {
setError("Product not found");
}
setProduct(found);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load product");
} finally {
setLoading(false);
}
}
fetchProduct();
return () => controller.abort();
}, [partId]);
// 2) Load offers for this product from Ballistic backend
useEffect(() => {
if (!partId) return;
let cancelled = false;
async function fetchOffers() {
try {
setOffersLoading(true);
setOffersError(null);
const data = await getRetailerOffers(partId);
if (!cancelled) {
setOffers(data);
}
} catch (err: any) {
if (!cancelled) {
setOffersError(err.message ?? "Failed to load retailer offers");
}
} finally {
if (!cancelled) {
setOffersLoading(false);
}
}
}
fetchOffers();
return () => {
cancelled = true;
};
}, [partId]);
// Best price: prefer live offers, fall back to summary product.price
const bestPrice = useMemo(() => {
if (offers.length > 0) {
return offers[0].price;
}
return product?.price ?? null;
}, [offers, product]);
if (!category) {
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">
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
<Link
href="/gunbuilder"
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Gunbuilder
</Link>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
{/* Breadcrumbs */}
<nav className="mb-4 text-xs text-zinc-500">
<Link
href="/gunbuilder"
className="hover:text-zinc-300 transition-colors"
>
Gunbuilder
</Link>
<span className="mx-1">/</span>
<Link
href={`/gunbuilder/${categoryId}`}
className="hover:text-zinc-300 transition-colors"
>
{category.name}
</Link>
{product && (
<>
<span className="mx-1">/</span>
<span className="text-zinc-300">{product.name}</span>
</>
)}
</nav>
{loading ? (
<p className="text-sm text-zinc-500">Loading product</p>
) : error || !product ? (
<div>
<h1 className="text-xl font-semibold mb-2">Product Unavailable</h1>
<p className="text-sm text-zinc-500 mb-4">
{error ?? "We couldnt find this product."}
</p>
<Link
href={`/gunbuilder/${categoryId}`}
className="text-amber-300 hover:text-amber-200 underline text-sm"
>
Back to {category.name} parts
</Link>
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-[minmax(0,2fr)_minmax(0,1.3fr)] items-start">
{/* Left: image + meta */}
<div className="space-y-4">
{product.mainImageUrl && (
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={product.mainImageUrl}
alt={product.name}
className="w-full object-contain max-h-80 bg-zinc-950"
/>
</div>
)}
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
{product.brand}
</div>
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
{product.name}
</h1>
<div className="grid grid-cols-2 gap-3 text-xs">
<p className="text-zinc-500">
Platform:{" "}
<span className="text-zinc-300">{product.platform}</span>
</p>
<p className="text-zinc-500">
Role:{" "}
<span className="text-zinc-300">{product.partRole}</span>
</p>
<p className="text-zinc-500">
Category:{" "}
<span className="text-zinc-300">{category.name}</span>
</p>
<p className="text-zinc-500">
Offers:{" "}
<span className="text-zinc-300">
{offers.length > 0
? `${offers.length} live offer${
offers.length === 1 ? "" : "s"
}`
: "No live offers yet"}
</span>
</p>
</div>
{/* Placeholder content until long-form fields are wired up */}
<section className="mt-4 space-y-3 text-sm text-zinc-300">
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Product Overview
</h2>
<p className="text-zinc-400">
This is placeholder copy for the product overview. Once
the Ballistic backend exposes richer metadata (short
description, feature bullets, etc.), we&apos;ll swap this
text out and surface the real content here.
</p>
<p className="text-zinc-500 text-xs">
Use this section to highlight what makes this part worth
a slot in your build: materials, intended use
(duty/range/competition), and any standout features.
</p>
</section>
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-3">
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500 mb-2">
Quick Specs (Placeholder)
</h2>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs text-zinc-400">
<div>
<dt className="text-zinc-500">Configuration</dt>
<dd className="text-zinc-200">
TBD (Stripped / Complete / Kit)
</dd>
</div>
<div>
<dt className="text-zinc-500">Caliber</dt>
<dd className="text-zinc-200">TBD from feed</dd>
</div>
<div>
<dt className="text-zinc-500">Finish</dt>
<dd className="text-zinc-200">TBD from merchant data</dd>
</div>
<div>
<dt className="text-zinc-500">Weight</dt>
<dd className="text-zinc-200">TBD (oz)</dd>
</div>
</dl>
<p className="mt-2 text-[11px] text-zinc-500">
Specs are placeholders for now. As we normalize more
structured attributes in the importer, this block will
auto-populate per product.
</p>
</section>
</div>
{/* Right: pricing + actions */}
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500 mb-1">
Price
</p>
<p className="text-2xl font-semibold text-amber-300">
{bestPrice && bestPrice > 0
? `$${bestPrice.toFixed(2)}`
: "—"}
</p>
{!bestPrice && (
<p className="mt-1 text-xs text-zinc-500">
Pricing not available yet. Live pricing will appear once
offers are imported.
</p>
)}
{offers.length > 0 && (
<p className="mt-1 text-[11px] text-zinc-500">
Showing the best available price across all live
offers for this part.
</p>
)}
{offers.length > 1 && (
<p className="mt-1 text-[11px] text-zinc-500">
Price range across retailers:{" "}
<span className="text-zinc-300">
${Math.min(...offers.map(o => o.price)).toFixed(2)} ${Math.max(...offers.map(o => o.price)).toFixed(2)}
</span>
</p>
)}
</div>
{/* Offers list */}
<div className="space-y-2">
{offersLoading && (
<p className="text-xs text-zinc-500">Loading offers</p>
)}
{offersError && (
<p className="text-xs text-red-400">{offersError}</p>
)}
{!offersLoading && !offersError && offers.length > 0 && (
<div className="space-y-1">
{offers.map((offer) => (
<a
key={offer.id}
href={offer.affiliateUrl}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
>
<span className="text-zinc-200">
{offer.retailerName}
</span>
<span className="font-semibold text-amber-300">
${offer.price.toFixed(2)}
</span>
</a>
))}
</div>
)}
{!offersLoading &&
!offersError &&
offers.length === 0 && (
<p className="text-xs text-zinc-500">
No live retailer offers yet. As feeds are imported,
merchants and pricing will show up here.
</p>
)}
</div>
<div className="space-y-2">
<Link
href={`/gunbuilder?select=${categoryId}:${product.id}`}
className="block w-full rounded-md bg-amber-400 text-black text-sm font-semibold text-center py-2.5 hover:bg-amber-300 transition-colors"
>
Add to Build
</Link>
{product.buyUrl ? (
<a
href={product.buyUrl}
target="_blank"
rel="noreferrer"
className="block w-full rounded-md border border-zinc-700 bg-zinc-900 text-xs font-medium text-zinc-200 text-center py-2 hover:bg-zinc-800 transition-colors"
>
View on Merchant Site
</a>
) : (
<button
type="button"
disabled
className="block w-full rounded-md border border-zinc-800 bg-zinc-900/60 text-xs font-medium text-zinc-500 text-center py-2 cursor-not-allowed"
>
External link coming soon
</button>
)}
</div>
</aside>
</div>
{/* Lower content: builder-focused helper copy (placeholder) */}
<section className="mt-10 grid gap-6 md:grid-cols-3 text-sm md:grid-cols-4">
<div className="space-y-2">
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Why We Like This Part
</h2>
<p className="text-zinc-400">
Drop in a short blurb here about what makes this part
worth picking over similar options think reliability,
track record, and value for the money.
</p>
</div>
<div className="space-y-2">
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Best For
</h2>
<p className="text-zinc-400">
Use this block to call out ideal use cases:{" "}
<span className="text-zinc-200">
duty rifle, home defense, range toy, competition, night
work
</span>
, etc.
</p>
</div>
<div className="space-y-2">
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Builder Notes
</h2>
<p className="text-zinc-400">
Add any compatibility quirks or install tips here once
the compatibility engine is wired up gas system length,
buffer recommendations, known fitment notes, and more.
</p>
</div>
<div className="space-y-2">
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Compatibility
</h2>
<div className="inline-flex items-center gap-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-[11px] text-amber-300">
<span className="inline-block h-2 w-2 rounded-full bg-amber-300 animate-pulse"></span>
Compatibility engine coming online soon
</div>
<p className="text-zinc-500 text-xs">
Fitment checks, caliber validation, buffer/gas system logic, and platform rules will appear here once the engine is active.
</p>
</div>
</section>
</>
)}
</div>
</main>
);
}
-806
View File
@@ -1,806 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
import type { CategoryId, Part } from "@/types/gunbuilder";
type ViewMode = "card" | "list";
type GunbuilderProductFromApi = {
id: string;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
buyUrl: string | null;
// optional stock flag if/when backend sends it
inStock?: boolean | null;
};
type UiPart = Part & {
// local-only stock flag for filtering
inStock?: boolean;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// sort options
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
// build state type + storage key (matches /gunbuilder)
type BuildState = Partial<Record<CategoryId, string>>;
const STORAGE_KEY = "gunbuilder-build-state";
export default function CategoryPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const router = useRouter();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// filter + sort state
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>("");
const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({
min: null,
max: null,
});
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number>(1);
const PAGE_SIZE = 24;
// build state for this page, synced with localStorage
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId],
);
useEffect(() => {
if (!categoryId) return;
const controller = new AbortController();
async function fetchCategoryParts() {
try {
setLoading(true);
setError(null);
// FIX: determine which backend partRoles map to this category
const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? [];
const search = new URLSearchParams();
search.set("platform", "AR-15");
roles.forEach((r) => search.append("partRoles", r));
const url = `${API_BASE_URL}/api/products/gunbuilder${
roles.length ? `?${search.toString()}` : ""
}`;
const res = await fetch(url, { 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) => ({
id: p.id,
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined,
notes: undefined,
inStock: p.inStock ?? true,
}));
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchCategoryParts();
return () => controller.abort();
}, [categoryId]);
// persist build to localStorage whenever it changes
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
} catch {
// ignore
}
}, [build]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery]);
// handler to toggle Add / Remove for this category
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
if (isSelected) {
// Remove from build
setBuild((prev) => {
const updated: BuildState = { ...prev };
delete updated[categoryId];
return updated;
});
} else {
// Add to build and navigate back to main gunbuilder page
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
router.push("/gunbuilder");
}
};
// available brands for filter
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
[parts],
);
const priceBounds = useMemo(() => {
if (parts.length === 0) {
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 };
}, [parts]);
// initialize / clamp priceRange whenever bounds change
useEffect(() => {
if (priceBounds.min == null || priceBounds.max == null) return;
setPriceRange((prev) => {
const min = prev.min ?? priceBounds.min!;
const max = prev.max ?? priceBounds.max!;
return {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
};
});
}, [priceBounds.min, priceBounds.max]);
const filteredParts = useMemo(() => {
let result = [...parts];
// Price range filter
const effectiveMin =
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
const effectiveMax =
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
if (effectiveMin != null && effectiveMax != null) {
result = result.filter((p) => {
const price = p.price ?? 0;
return price >= effectiveMin && price <= effectiveMax;
});
}
// Brand filter
if (brandFilter.length > 0) {
result = result.filter((p) => brandFilter.includes(p.brand));
}
// In-stock filter
if (inStockOnly) {
result = result.filter((p) => p.inStock ?? true);
}
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery) {
result = result.filter((p) => {
const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase();
return (
name.includes(normalizedQuery) ||
brand.includes(normalizedQuery)
);
});
}
switch (sortBy) {
case "price-asc":
result.sort((a, b) => a.price - b.price);
break;
case "price-desc":
result.sort((a, b) => b.price - a.price);
break;
case "brand-asc":
result.sort((a, b) => a.brand.localeCompare(b.brand));
break;
case "relevance":
default:
// leave in backend order
break;
}
// Pin the currently selected part (for this category) to the top, if any
const selectedId = build[categoryId];
if (selectedId) {
const selected = result.filter((p) => p.id === selectedId);
const others = result.filter((p) => p.id !== selectedId);
result = [...selected, ...others];
}
return result;
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
const totalPages = useMemo(
() => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)),
[filteredParts, 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, PAGE_SIZE]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
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, PAGE_SIZE]);
if (!category) {
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">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Category Not Found
</h1>
<Link
href="/gunbuilder"
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Gunbuilder
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<Link
href="/gunbuilder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
{category.name} <span className="text-amber-300">Parts</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Browse all available {category.name.toLowerCase()} options for
your build, pulled live from your Ballistic backend.
</p>
</div>
{!loading && !error && parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="Card view"
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="List view"
>
List
</button>
</div>
)}
</div>
</header>
{/* Parts Grid/List */}
<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">
{/* Left sidebar: filters */}
{!loading && !error && parts.length > 0 && (
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the {category.name.toLowerCase()} list.
</p>
</div>
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{priceRange.min !== null || priceRange.max !== null ? (
<button
type="button"
onClick={() =>
setPriceRange({
min: priceBounds.min,
max: priceBounds.max,
})
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Reset
</button>
) : null}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">
No price data available.
</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
$
{(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
$
{(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: Math.min(
value,
prev.max ?? priceBounds.max ?? value
),
max:
prev.max ??
priceBounds.max ??
value,
}));
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min:
prev.min ??
priceBounds.min ??
value,
max: Math.max(
value,
prev.min ?? priceBounds.min ?? value
),
}));
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">
No brands available yet.
</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter((prev) =>
e.target.checked
? [...prev, b]
: prev.filter((brand) => brand !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</aside>
)}
{/* Right side: toolbar + results */}
<div className="flex-1">
{/* Top toolbar: count + search + sort */}
{!loading && !error && parts.length > 0 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{filteredParts.length}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label
htmlFor="part-search"
className="text-xs text-zinc-500"
>
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${category.name.toLowerCase()}...`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="sort-by"
className="text-xs text-zinc-500"
>
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) =>
setSortBy(e.target.value as SortOption)
}
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"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div>
)}
{/* Results / states */}
{loading ? (
<p className="py-8 text-center text-sm text-zinc-500">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="mb-3 flex items-center gap-2 justify-between">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal">
{part.name}
</span>
</div>
{part.notes && (
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
<div className="mt-2 flex gap-2">
<Link
href={`/gunbuilder/${categoryId}/${part.id}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() => handleTogglePart(categoryId, part.id)}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
);
})}
</div>
) : (
<>
{/* Header row for list view */}
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
<span>Part</span>
<span>Brand</span>
<span className="text-right">Price</span>
<span className="pr-2 text-right">Actions</span>
</div>
<div className="space-y-2">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.name}
</div>
{part.notes && (
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)}
</div>
<div className="flex justify-end gap-2">
<Link
href={`/gunbuilder/${categoryId}/${part.id}`}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{/* Pagination controls */}
{filteredParts.length > 0 && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">
{currentPage}
</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</section>
</div>
</main>
);
}
-422
View File
@@ -1,422 +0,0 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type GunbuilderProductFromApi = {
id: string; // backend UUID string
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
buyUrl: string | null;
};
// Map backend partRole -> CategoryId (same as /gunbuilder page)
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
"upper-receiver": "upper",
barrel: "barrel",
handguard: "handguard",
"charging-handle": "chargingHandle",
"buffer-kit": "buffer",
"lower-parts-kit": "lowerParts",
sight: "sights",
};
// Encode build state to URL-friendly string
function encodeBuildState(build: BuildState): string {
const entries = Object.entries(build)
.filter(([_, partId]) => partId)
.map(([categoryId, partId]) => `${categoryId}:${partId}`)
.join(",");
return btoa(entries);
}
// Decode URL string to build state
function decodeBuildState(encoded: string): BuildState {
try {
const decoded = atob(encoded);
const build: BuildState = {};
decoded.split(",").forEach((entry) => {
const [categoryId, partId] = entry.split(":");
if (categoryId && partId) {
build[categoryId as CategoryId] = partId;
}
});
return build;
} catch {
return {};
}
}
export default function BuildDetailsPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [build, setBuild] = useState<BuildState>({});
const [shareUrl, setShareUrl] = useState<string>("");
const [copied, setCopied] = useState(false);
const [parts, setParts] = useState<Part[]>([]);
const [loadingParts, setLoadingParts] = useState(true);
const [partsError, setPartsError] = useState<string | null>(null);
// Load build from URL params or localStorage
useEffect(() => {
const buildParam = searchParams.get("build");
if (buildParam) {
const decoded = decodeBuildState(buildParam);
setBuild(decoded);
// Also save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
} else if (typeof window !== "undefined") {
// Load from localStorage
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
setBuild(parsed);
} catch {
// Invalid stored data
}
}
}
}, [searchParams]);
// Fetch live parts from backend (same source as /gunbuilder)
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoadingParts(true);
setPartsError(null);
const res = await fetch(
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
{ signal: controller.signal },
);
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: Part[] = data
.map((p): Part | null => {
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
if (!categoryId) return null;
return {
id: p.id, // already a string UUID
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined,
notes: undefined,
};
})
.filter(Boolean) as Part[];
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setPartsError(err.message ?? "Failed to load products");
} finally {
setLoadingParts(false);
}
}
fetchProducts();
return () => controller.abort();
}, []);
// Generate shareable URL
useEffect(() => {
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
const encoded = encodeBuildState(build);
const url = `${window.location.origin}/gunbuilder/build?build=${encoded}`;
setShareUrl(url);
}
}, [build]);
const selectedParts: Part[] = useMemo(() => {
if (parts.length === 0) return [];
return Object.entries(build)
.map(([categoryId, partId]) =>
parts.find(
(p) =>
p.id === partId && p.categoryId === (categoryId as CategoryId),
),
)
.filter(Boolean) as Part[];
}, [build, parts]);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts],
);
const handleCopyLink = async () => {
if (shareUrl) {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
};
const handleShare = async () => {
if (navigator.share && shareUrl) {
try {
await navigator.share({
title: "My AR-15 Build",
text: `Check out my AR-15 build: $${totalPrice.toFixed(2)}`,
url: shareUrl,
});
} catch {
handleCopyLink();
}
} else {
handleCopyLink();
}
};
if (!loadingParts && selectedParts.length === 0) {
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-4xl px-4 py-6 lg:py-10">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
No Build Selected
</h1>
<p className="text-sm text-zinc-400 mb-6">
You need to select at least one part to view your build.
</p>
<Link
href="/gunbuilder"
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Go to Gunbuilder
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<Link
href="/gunbuilder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Build <span className="text-amber-300">Summary</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Review your build, purchase parts from our affiliate partners, or
share your build with others.
</p>
</div>
<div className="mt-3 md:mt-0">
<div className="text-xs text-zinc-400">Total Build Price</div>
<div className="text-3xl font-semibold text-amber-300">
${totalPrice.toFixed(2)}
</div>
<div className="text-xs text-zinc-500">
{selectedParts.length} / {CATEGORIES.length} parts selected
</div>
</div>
</div>
</header>
{/* Share Section */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/60 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400 mb-2">
Share Your Build
</h2>
<p className="text-sm text-zinc-400">
Share this link to let others view your build or bookmark it to
come back later.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleShare}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Share
</button>
<button
type="button"
onClick={handleCopyLink}
className={`rounded-md border px-4 py-2 text-sm font-medium transition-colors ${
copied
? "border-green-500 bg-green-500/10 text-green-400"
: "border-zinc-700 bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{copied ? "Copied!" : "Copy Link"}
</button>
</div>
</div>
{shareUrl && (
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
<p className="text-xs text-zinc-400 break-all font-mono">
{shareUrl}
</p>
</div>
)}
</section>
{/* Build Parts */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400 mb-4">
Selected Parts
</h2>
{loadingParts && (
<p className="text-sm text-zinc-500 mb-4">Loading parts</p>
)}
{partsError && (
<p className="text-sm text-red-400 mb-4">
{partsError} check the Ballistic API.
</p>
)}
<div className="space-y-4">
{CATEGORIES.map((category) => {
const selectedPartId = build[category.id];
const part = parts.find(
(p) =>
p.id === selectedPartId &&
p.categoryId === (category.id as CategoryId),
);
return (
<div
key={category.id}
className="border border-zinc-800 rounded-md p-4 bg-zinc-900/30"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
{category.name}
</div>
{part ? (
<>
<div className="text-lg font-semibold text-zinc-50 mb-1">
<span className="text-zinc-400">{part.brand}</span>{" "}
{part.name}
</div>
<div className="mt-3 flex gap-2 flex-wrap">
{part.url && (
<a
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Purchase from Retailer
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)}
<Link
href={`/gunbuilder/${category.id}/${part.id}`}
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
View Details
</Link>
</div>
</>
) : (
<div className="text-sm text-zinc-600">
Not selected
</div>
)}
</div>
{part && (
<div className="text-right">
<div className="text-2xl font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</section>
{/* Actions */}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
<Link
href="/gunbuilder"
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
>
Edit Build
</Link>
<div className="flex gap-3">
<button
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
router.push("/gunbuilder");
}}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Clear Build
</button>
</div>
</div>
</div>
</main>
);
}
-687
View File
@@ -1,687 +0,0 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings";
import { Pencil, Link2 } from "lucide-react";
type GunbuilderProductFromApi = {
id: number; // backend numeric id
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
buyUrl: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
// Category groups for the main grid
const CATEGORY_GROUPS: {
id: string;
label: string;
description?: string;
categoryIds: CategoryId[];
}[] = [
{
id: "lower-group",
label: "Lower Receiver Parts",
description:
"Everything from the serialized lower to small parts, fire control, and core controls.",
categoryIds: [
"lower",
"completeLower",
"lowerParts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
],
},
{
id: "upper-group",
label: "Upper Receiver Parts",
description:
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
categoryIds: [
"upper",
"completeUpper",
"bcg",
"barrel",
"gasBlock",
"gasTube",
"muzzleDevice",
"suppressor",
"handguard",
"chargingHandle",
"sights",
"optic",
],
},
// Optional: future group for accessories/furniture if needed
// {
// id: "accessories-group",
// label: "Accessories & Furniture",
// description: "Optics, lights, slings, and other supporting gear.",
// categoryIds: ["optic", "sights", ...] as CategoryId[],
// },
];
export default function GunbuilderPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return {};
}
}
}
return {};
});
const [shareStatus, setShareStatus] = useState<string | null>(null);
const [shareUrl, setShareUrl] = useState<string>("");
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoading(true);
setError(null);
const res = await fetch(
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
{ signal: controller.signal },
);
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: Part[] = data
.map((p): Part | null => {
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
if (!categoryId) {
// Skip any parts we don't know how to map yet
return null;
}
const buyUrl = p.buyUrl ?? undefined;
return {
id: String(p.id), // ALWAYS string
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
affiliateUrl: buyUrl, // main link
url: buyUrl, // 👈optional alias if you still reference .url. maybe pretty url?
notes: undefined,
};
})
.filter((p): p is Part => p !== null);
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchProducts();
return () => controller.abort();
}, []);
// Handle URL query parameter for part selection (?select=upper:165)
useEffect(() => {
const selectParam = searchParams.get("select");
if (selectParam) {
const [categoryId, partId] = selectParam.split(":");
if (categoryId && partId && CATEGORIES.some((c) => c.id === categoryId)) {
setBuild((prev) => {
const updated = {
...prev,
[categoryId as CategoryId]: partId,
};
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
}
return updated;
});
router.replace("/gunbuilder", { scroll: false });
}
}
}, [searchParams, router]);
// Persist build state to localStorage whenever it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
}
}, [build]);
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) {
grouped[category.id] = parts.filter(
(p) => p.categoryId === category.id,
);
}
return grouped;
}, [parts]);
const selectedParts: Part[] = useMemo(() => {
return Object.entries(build)
.map(([categoryId, partId]) =>
parts.find((p) => p.id === partId && p.categoryId === categoryId),
)
.filter(Boolean) as Part[];
}, [build, parts]);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts],
);
useEffect(() => {
if (typeof window === "undefined") return;
// If no parts selected, clear the share URL
if (Object.keys(build).length === 0) {
setShareUrl("");
return;
}
try {
const payload = JSON.stringify(build);
const encoded = window.btoa(payload);
const origin = window.location?.origin ?? "";
const url = `${origin}/gunbuilder/build?build=${encodeURIComponent(
encoded,
)}`;
setShareUrl(url);
} catch {
setShareUrl("");
}
}, [build]);
const handleSelectPart = (categoryId: CategoryId, partId: string) => {
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
};
// Use group ordering for the summary as well
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter(
(id) => CATEGORIES.some((c) => c.id === id),
),
[],
);
const handleShare = async () => {
if (selectedParts.length === 0) {
setShareStatus("Add a few parts before sharing your build.");
return;
}
const lines: string[] = [];
lines.push("Shadow Standard — The Armory Build");
lines.push("");
lines.push(`Total: $${totalPrice.toFixed(2)}`);
lines.push("");
summaryCategoryOrder.forEach((categoryId) => {
const cat = CATEGORIES.find((c) => c.id === categoryId);
if (!cat) return;
const selectedPartId = build[cat.id];
const part = parts.find(
(p) => p.id === selectedPartId && p.categoryId === cat.id,
);
if (part) {
lines.push(
`${cat.name}: ${part.brand}${part.name} ($${part.price.toFixed(
2,
)})`,
);
} else {
lines.push(`${cat.name}: [Not selected]`);
}
});
const text = lines.join("\n");
try {
await navigator.clipboard.writeText(text);
setShareStatus("Build summary copied to clipboard.");
} catch {
setShareStatus(
"Could not access clipboard. You can copy from the build summary page.",
);
}
};
const handleCopyLink = async () => {
if (!shareUrl) {
setShareStatus("No shareable link available yet.");
return;
}
try {
await navigator.clipboard.writeText(shareUrl);
setShareStatus("Shareable link copied to clipboard.");
} catch {
setShareStatus("Could not copy link to clipboard.");
}
};
const handleNativeShare = async () => {
if (!shareUrl) {
setShareStatus("No shareable link available yet.");
return;
}
if (navigator.share) {
try {
await navigator.share({
title: "Shadow Standard — The Armory Build",
text: "Check out my Shadow Standard Armory build.",
url: shareUrl,
});
setShareStatus("Share dialog opened.");
} catch {
// User canceled or share failed; keep it quiet but let them know
setShareStatus("Share canceled or unavailable. You can copy the link instead.");
}
} else {
// Fallback to copying the link
await handleCopyLink();
}
};
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard Co.
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
The Build Bench: <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
category, track live prices, and watch your total build cost
update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime.
</p>
</div>
</header>
{/* Build summary panel */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
{/* Left: title + totals + primary actions */}
<div className="flex flex-col gap-2">
<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">
${totalPrice.toFixed(2)}
</div>
<div className="text-xs text-zinc-500 mt-1">
{selectedParts.length} / {CATEGORIES.length} categories filled
</div>
</div>
{/* Primary actions now live under the total */}
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
<Link
href="/gunbuilder/build"
className={`w-full sm:w-auto rounded-md border border-amber-400/60 bg-amber-400/10 px-3 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 text-center transition-colors ${
selectedParts.length === 0
? "opacity-40 cursor-not-allowed pointer-events-none"
: ""
}`}
>
Build Summary
</Link>
<button
type="button"
onClick={handleShare}
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
selectedParts.length === 0
? "bg-zinc-900/80 text-zinc-600 border-zinc-800 cursor-not-allowed"
: "bg-zinc-900/80 text-zinc-200 border-zinc-700 hover:bg-zinc-800"
}`}
disabled={selectedParts.length === 0}
>
Copy Build Summary
</button>
<button
type="button"
onClick={() => {
setBuild({});
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
}}
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 ${
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
}`}
>
Clear Build
</button>
</div>
</div>
{/* Right: Share Your Build */}
<div className="flex flex-col gap-3 md:items-end md:text-right md:flex-1">
{selectedParts.length > 0 && shareUrl && (
<div className="w-full md:w-auto">
<h3 className="text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-zinc-400">
Share Your Build
</h3>
<p className="mt-1 text-[0.7rem] text-zinc-500">
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">
Shareable URL
</div>
<input
type="text"
readOnly
value={shareUrl}
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"
onClick={handleNativeShare}
className="flex-1 md:flex-none rounded-md bg-zinc-900/80 px-3 py-1.5 text-[0.75rem] font-semibold text-zinc-100 border border-zinc-700 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
>
Share
</button>
<button
type="button"
onClick={handleCopyLink}
className="flex-1 md:flex-none rounded-md bg-amber-400 px-3 py-1.5 text-[0.75rem] font-semibold text-black hover:bg-amber-300 transition-colors"
>
Copy Link
</button>
</div>
</div>
</div>
)}
{shareStatus && (
<p className="mt-1 text-[0.7rem] text-zinc-500">
{shareStatus}
</p>
)}
</div>
</section>
{/* Layout */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your
current pick for that part type, with price and a direct buy
linkor a quick way to choose a part if you haven&apos;t picked
one yet.
</p>
{loading && (
<p className="text-sm text-zinc-500">
Loading parts from backend
</p>
)}
{error && !loading && (
<p className="text-sm text-red-400">
{error} check that the Ballistic API is running and CORS is
configured.
</p>
)}
{!loading && !error && (
<div className="space-y-6">
{CATEGORY_GROUPS.map((group) => {
const groupCategories = CATEGORIES.filter((c) =>
group.categoryIds.includes(c.id as CategoryId),
);
if (groupCategories.length === 0) {
return null;
}
return (
<div key={group.id} className="space-y-3">
<div className="flex items-baseline justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-zinc-100">
{group.label}
</h2>
{group.description && (
<p className="text-xs text-zinc-500 mt-1 max-w-xl">
{group.description}
</p>
)}
</div>
</div>
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-3 py-2 text-left font-semibold text-zinc-400">
Component / Part Type
</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">
Brand
</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">
Price
</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">
Sale Price
</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">
Caliber
</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">
Buy / Choose
</th>
</tr>
</thead>
<tbody>
{groupCategories.map((category) => {
const categoryParts =
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id];
const selectedPart = categoryParts.find(
(p) => p.id === selectedPartId,
);
const hasParts = categoryParts.length > 0;
return (
<tr
key={category.id}
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
>
{/* Component / Part Type */}
<td className="px-3 py-2 align-top">
<div className="font-medium text-zinc-100">
{category.name}
</div>
{selectedPart ? (
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name}
</div>
) : hasParts ? (
<div className="text-[0.7rem] text-zinc-600">
No part selected
</div>
) : (
<div className="text-[0.7rem] text-zinc-600">
No parts available yet
</div>
)}
</td>
{/* Brand */}
<td className="px-3 py-2 align-top text-zinc-300">
{selectedPart ? selectedPart.brand : "—"}
</td>
{/* Price */}
<td className="px-3 py-2 align-top text-right text-amber-300">
{selectedPart
? `$${selectedPart.price.toFixed(2)}`
: "—"}
</td>
{/* Sale Price (placeholder until we wire through sale/original) */}
<td className="px-3 py-2 align-top text-right text-zinc-400">
{/* TODO: wire in sale/original prices from backend */}
{selectedPart ? "—" : "—"}
</td>
{/* Caliber (placeholder for now) */}
<td className="px-3 py-2 align-top text-zinc-400">
{/* TODO: wire in caliber from ProductSummaryDto when available */}
</td>
{/* Buy / Choose */}
<td className="px-3 py-2 align-top">
<div className="flex justify-end gap-2">
{selectedPart && selectedPart.url ? (
<>
<Link
href={`/gunbuilder/${category.id}`}
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
aria-label="Change part"
title="Change part"
>
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
</Link>
<a
href={selectedPart.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2 py-1 hover:bg-amber-300 transition-colors"
aria-label="Buy part"
title="Buy part"
>
<Link2 className="h-3.5 w-3.5 text-black" />
</a>
</>
) : hasParts ? (
<Link
href={`/gunbuilder/${category.id}`}
className="inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
onClick={() => {
// If you want to pre-select something later, you can handle here.
}}
>
Choose a Part
</Link>
) : (
<span className="text-[0.7rem] text-zinc-600">
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
</div>
)}
</section>
{/* What's New */}
<section className="mt-8 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4">
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
What&apos;s New in Early Access
</h2>
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
<li> Live parts and pricing pulled from multiple merchants.</li>
<li>
Grouped layout for lower and upper receiver parts so you can see
at a glance which sections of your build are still missing.
</li>
<li>
Running build total that updates automatically as you add or
swap components.
</li>
<li>
Local build persistence so your selections stick around between
visits on this device.
</li>
</ul>
</section>
{/* Roadmap */}
<section className="mt-8 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4">
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
What&apos;s on our roadmap
</h2>
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
<li> Platform filters (AR-15, AR-9, AR-10)</li>
<li> More part categories and furniture</li>
<li> Richer pricing/stock sync</li>
<li> Smarter compatibility checks between components</li>
<li> AR9/AR10 support and more</li>
</ul>
</section>
</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>
);
}
+18
View File
@@ -0,0 +1,18 @@
// app/(builder)/layout.tsx
import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { BuilderNav } from "@/components/BuilderNav";
import { TopNav } from "@/components/TopNav";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-neutral-950 text-zinc-50">
<AuthProvider>
<TopNav />
<BuilderNav />
<main className="min-h-screen">{children}</main>
</AuthProvider>
</div>
);
}
+293 -178
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">
@@ -129,18 +210,19 @@ export default function BuildsPage() {
Battl Builder · Build Book
</p>
<h1
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
>
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,100 +360,117 @@ export default function BuildsPage() {
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
>
</button>
</div>
{filteredBuilds.map((build) => {
const dollars = Math.floor((build.price ?? 0) / 100);
{/* Placeholder 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`}
className="h-full w-full object-cover"
/>
return (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
{/* 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={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
<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 > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Placeholder build listing details, parts list, and
comments will live on the build detail page later.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
${build.price.toLocaleString()}
</div>
</div>
</div>
{tag}
</span>
))}
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments, save, and share coming soon.
</span>
</div>
</div>
</div>
</article>
))}
</article>
);
})}
{filteredBuilds.length === 0 && (
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
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>
@@ -363,4 +478,4 @@ export default function BuildsPage() {
</div>
</main>
);
}
}
+37 -12
View File
@@ -1,28 +1,53 @@
// app/layout.tsx
import "./globals.css";
import type { ReactNode } from "react";
import Script from "next/script";
import { AuthProvider } from "@/context/AuthContext";
import { TopNav } from "@/components/TopNav";
import { BuilderNav } from "@/components/BuilderNav";
import { Banner } from "@/components/Banner";
export const metadata = {
title: {
default: "Battl Builder",
template: "%s | Battl Builder",
default: "Battl Builders",
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" },
],
},
description: "Battl Builder — the smarter, faster, datadriven way to build your next firearm.",
};
const themeScript = `
(function () {
try {
const storedTheme = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = storedTheme || (prefersDark ? "dark" : "light");
document.documentElement.classList.toggle("dark", theme === "dark");
} catch (_) {}
})();
`;
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="bg-black text-zinc-100">
<html lang="en" suppressHydrationWarning className="dark">
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
{" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
strategy="lazyOnload"
/>
<AuthProvider>
<TopNav />
<BuilderNav />
<main className="min-h-screen">{children}</main>
<Banner />
{children}
</AuthProvider>
</body>
</html>
);
}
}
+117 -33
View File
@@ -1,10 +1,14 @@
// 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();
@@ -13,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") || "/gunbuilder";
// 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();
@@ -25,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);
}
}
@@ -33,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">
@@ -46,54 +105,79 @@ export default function LoginPage() {
</div>
)}
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-400" htmlFor="email">
Email
</label>
<input
<Field label="Email" htmlFor="email">
<Input
id="email"
type="email"
autoComplete="email"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</Field>
<div className="space-y-1">
<label
className="text-xs font-medium text-zinc-400"
htmlFor="password"
>
Password
</label>
<input
<Field label="Password" htmlFor="password">
<Input
id="password"
type="password"
autoComplete="current-password"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</Field>
<button
type="submit"
disabled={loading}
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Button type="submit" disabled={loading}>
{loading ? "Signing in…" : "Log In"}
</button>
</Button>
</form>
<p className="mt-4 text-xs text-zinc-500">
New here?{" "}
<Link
href="/register"
className="text-amber-300 hover:text-amber-200"
{/* 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
</Link>
.
@@ -101,4 +185,4 @@ export default function LoginPage() {
</div>
</main>
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
export default function NotFound() {
return (
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
<div className="max-w-md text-center">
<p className="text-xs font-semibold tracking-[0.3em] uppercase text-zinc-500">
Battl Builders
</p>
<h1 className="mt-4 text-4xl font-semibold tracking-tight">
Page Not Found
</h1>
<p className="mt-3 text-sm text-zinc-400">
This area of the site isnt publicly accessible yet.
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
href="/"
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
>
Go Home
</Link>
{/* <a
href="https://battl.builders"
className="rounded-md border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-900 hover:border-zinc-600 transition-colors"
>
Learn More
</a> */}
</div>
<p className="mt-6 text-[11px] text-zinc-600">
Early access features are launching soon.
</p>
</div>
</main>
);
}
+246 -26
View File
@@ -1,33 +1,253 @@
import Link from "next/link";
"use client";
import { FormEvent, useState } from "react";
import Image from "next/image";
export default function HomePage() {
const [email, setEmail] = useState("");
const [useCase, setUseCase] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [message, setMessage] = useState<string | null>(null);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!email) {
setMessage("Drop an email in first, operator.");
setStatus("error");
return;
}
try {
setStatus("loading");
setMessage(null);
const res = await fetch("/api/beta-signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, useCase }),
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || "Failed to save your signup.");
}
setStatus("success");
setMessage("✅ Youre on the list. Invites drop soon — well email your access link when its go-time.");
setEmail("");
setUseCase("");
} catch (err) {
console.error(err);
setStatus("error");
setMessage("Something broke. Try again in a minute.");
}
}
return (
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
<div className="max-w-xl text-center">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
For Those Who Build With Intent.
</p>
<h1
className="mt-2 text-4xl md:text-5xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent uppercase bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
<main className="min-h-screen bg-black text-zinc-50">
{/* Abstract Brand Background */}
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-[0.55]">
<svg
width="100%"
height="100%"
preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg"
>
Battl Builder
</h1>
<p className="mt-3 text-sm md:text-base text-zinc-400">
Battl Builder is your command center for rifle builds. We surface live
parts and hard-use brands, giving you a mission-ready workbench to
design smarter, track cost, swap components, and save setups. No
fluff. Just clean, trustworthy data.
</p>
<div className="mt-6">
<Link
href="/gunbuilder"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15"
>
Launch The Builder
</Link>
</div>
<defs>
<pattern
id="battlGrid"
x="0"
y="0"
width="80"
height="80"
patternUnits="userSpaceOnUse"
>
<path
d="M 80 0 L 0 0 0 80"
fill="none"
stroke="#fbbf24"
strokeWidth="0.5"
strokeOpacity="0.35"
/>
</pattern>
<pattern
id="battlHex"
width="140"
height="140"
patternUnits="userSpaceOnUse"
>
<path
d="M70 4 L136 40 L136 110 L70 146 L4 110 L4 40 Z"
fill="none"
stroke="#fbbf24"
strokeWidth="0.4"
strokeOpacity="0.25"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#battlGrid)" />
<rect width="100%" height="100%" fill="url(#battlHex)" />
</svg>
</div>
<div className="mx-auto flex min-h-screen max-w-5xl flex-col px-4 py-10">
{/* Top Bar / Logo */}
<header className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center">
<Image
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo"
width={260} // adjust to taste
height={48} // adjust to taste
className="block"
/>
</div>
</div>
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-300">
Private Beta Coming Soon
</span>
</header>
{/* Hero */}
<section className="mt-16 grid gap-10 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] md:items-center">
<div>
<h1 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
Stop Guessing.
<span className="block text-amber-300 py-0.16em">
Start Building.
</span>
</h1>
<p className="mt-4 max-w-xl text-sm text-zinc-400 sm:text-base">
From part comparisons to full-build matchups, Battl Builders helps
you find the right components, catch compatibility issues, and
score the best deals all without juggling tabs or spreadsheets.
</p>
<ul className="mt-6 space-y-2 text-sm text-zinc-300">
<li> Side-by-side part & build comparisons</li>
<li> Smart compatibility checks</li>
<li> Live pricing from vetted merchants</li>
<li> Save & share builds with your crew</li>
<li> Vote on the cleanest, meanest setups</li>
</ul>
</div>
{/* Beta Form */}
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-5 shadow-[0_0_0_1px_rgba(24,24,27,0.9)]">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Join the Beta List
</h2>
<p className="mt-2 text-xs text-zinc-400">
Drop your email and we&apos;ll send early access when the Builder
goes hot. No spam, just builds.
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<div>
<label
htmlFor="email"
className="text-xs font-medium text-zinc-300"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@gearjunkie.com"
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>
<div>
<label
htmlFor="useCase"
className="text-xs font-medium text-zinc-300"
>
What will you use Battl Builders for?
<span className="text-zinc-500"> (optional)</span>
</label>
<textarea
id="useCase"
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 disabled:opacity-60 disabled:cursor-not-allowed"
/>
</div>
<button
type="submit"
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…"
: status === "success"
? "On the List ✅"
: "Get Beta Access"}
</button>
{message && (
<p
className={`text-xs ${
status === "success"
? "text-emerald-400"
: status === "error"
? "text-red-400"
: "text-zinc-400"
}`}
>
{message}
</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>
{/* 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 className="text-zinc-600">
v0.1 Import engine online Builder UI in progress
</span>
</div>
</footer>
</div>
</main>
);
+58 -79
View File
@@ -5,6 +5,7 @@ import { FormEvent, 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";
export default function RegisterPage() {
const router = useRouter();
@@ -16,7 +17,7 @@ export default function RegisterPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/gunbuilder";
const next = searchParams.get("next") || "/builder";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
@@ -31,89 +32,67 @@ export default function RegisterPage() {
}
return (
<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">
Join the <span className="text-amber-300">Shadow Standard</span> beta
</h1>
<p className="mt-2 text-sm text-zinc-400">
Create an account so we can save your builds, watch price drops, and
roll out new features to you first.
</p>
<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">
Join the <span className="text-amber-300">Battl Builder</span> Beta
</h1>
<p className="mt-2 text-sm text-zinc-400">
Create an account so we can save your builds, watch price drops, and
roll out new features to you first.
</p>
<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">
{error}
</div>
)}
<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">
{error}
</div>
)}
<div className="space-y-1">
<label
className="text-xs font-medium text-zinc-400"
htmlFor="displayName"
>
Display Name (optional)
</label>
<input
id="displayName"
type="text"
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<Field label="Display Name (optional)" htmlFor="displayName">
<Input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</Field>
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-400" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Field label="Email" htmlFor="email">
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<div className="space-y-1">
<label
className="text-xs font-medium text-zinc-400"
htmlFor="password"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Field label="Password" htmlFor="password">
<Input
id="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<button
type="submit"
disabled={loading}
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? "Creating account…" : "Join Beta"}
</button>
</form>
<Button type="submit" disabled={loading}>
{loading ? "Creating account…" : "Join Beta"}
</Button>
</form>
<p className="mt-4 text-xs text-zinc-500">
Already have an account?{" "}
<Link href="/login" className="text-amber-300 hover:text-amber-200">
Log in
</Link>
.
</p>
</div>
</main>
<p className="mt-4 text-xs text-zinc-500">
Already have an account?{" "}
<Link href="/login" className="text-amber-300 hover:text-amber-200">
Log in
</Link>
.
</p>
</div>
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import type React from "react";
import Link from "next/link";
import {
LayoutDashboard,
Download,
Layers,
Boxes,
Store,
Users,
Settings,
LucideMail,
Wand2,
} from "lucide-react";
export type AdminNavItem = {
label: string;
href: string;
icon: React.ReactNode;
};
type AdminLeftNavigationProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
items?: AdminNavItem[]; // ✅ NEW
};
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
className={`hidden border-r border-zinc-900 bg-zinc-950/80 px-3 py-6 md:flex md:flex-col transition-all duration-200 ${
collapsed ? "w-16" : "w-60"
}`}
>
<div className="mb-6 flex items-center gap-2">
<button
type="button"
onClick={onToggleCollapsed}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 text-zinc-400 transition hover:border-amber-400/60 hover:text-amber-300"
>
<span className="sr-only">Toggle sidebar</span>
<div className="space-y-0.5">
<span className="block h-[1px] w-3 bg-current" />
<span className="block h-[1px] w-3 bg-current" />
<span className="block h-[1px] w-3 bg-current" />
</div>
</button>
{!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>
</p>
<p className="text-[10px] text-zinc-600">Admin Command</p>
</div>
)}
</div>
<nav className="flex-1 space-y-1 text-sm">
{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"
>
<div className="flex items-center gap-2">
{item.icon}
{!collapsed && <span>{item.label}</span>}
</div>
{!collapsed && <span className="text-[10px] text-zinc-600"></span>}
</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="mt-1">
Import engine: <span className="text-emerald-400">online</span>
</p>
<p className="text-zinc-500">Builder UI: in progress</p>
</div>
)}
</aside>
);
}
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
export function Banner() {
return (
<div>
{/* Early access bar */}
<div className="border-b border-amber-500/20 bg-amber-500/5">
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
Early Access Beta
</span>
<span className="hidden text-amber-100/90 md:inline">
You&apos;re using an early-access prototype of the Builder. Data,
pricing, and available parts are still evolving.
</span>
</div>
<span className="text-amber-100/90 md:hidden">
Early-access prototype. Data and pricing may change.
</span>
</div>
</div>
</div>
);
}
export default Banner;
+113 -30
View File
@@ -1,15 +1,34 @@
"use client";
/**
* BuilderNav.tsx
* -----------------------------------------------------------------------------
* Top navigation for the Builder experience.
*
* MAJOR LAYOUT IDEA:
* - Keep all primary nav items on the LEFT
* - Put *everything else* (search, viewing state, theme toggle later, user menu later)
* into a RIGHT "actions cluster" using a single `ml-auto` wrapper.
*
* WHY:
* - Prevents multiple `ml-auto` elements fighting each other
* - Makes the right side easy to extend without breaking alignment
*/
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { Category } from "@/types/gunbuilder";
type BuilderNavProps = {
// Optional override if a parent wants to force the active category
activeCategoryId?: string | null;
};
// Helper to group categories for the dropdowns
/**
* Helper: group categories for dropdowns.
* This assumes your Category has a `group` field like "lower" | "upper" | "accessories".
*/
function groupCategories(group: "lower" | "upper" | "accessories"): Category[] {
return CATEGORIES.filter((c) => c.group === group);
}
@@ -17,32 +36,45 @@ function groupCategories(group: "lower" | "upper" | "accessories"): Category[] {
export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
const searchParams = useSearchParams();
// ---- Data / derived state --------------------------------------------------
const lower = groupCategories("lower");
const upper = groupCategories("upper");
const accessories = groupCategories("accessories");
// Determine which category is "active" so we can highlight dropdown items
// and show the "Viewing" indicator on the right.
const currentCategory =
activeCategoryId ?? searchParams.get("category") ?? undefined;
const baseHref = "/gunbuilder";
// Base route for parts browsing (used for dropdown links + "Clear")
const baseHref = "/parts";
/**
* Dropdown renderer (hover-based).
* - Uses Tailwind `group` + `group-hover` to show/hide the menu.
* - Highlights the active item.
*/
const renderDropdown = (label: string, items: Category[]) => {
if (!items.length) return null;
const platform = searchParams.get("platform");
return (
<div className="relative group">
{/* Dropdown trigger */}
<button
type="button"
className={`inline-flex items-center gap-1 font-medium tracking-wide transition-colors uppercase text-[11px] ${
items.some(cat => cat.id === currentCategory)
className={`inline-flex items-center gap-1 font-medium tracking-wide uppercase text-[11px] transition-colors ${
items.some((cat) => cat.id === currentCategory)
? "text-white"
: "text-neutral-200 hover:text-white"
: "text-neutral-300 hover:text-white"
}`}
>
{label}
<span className="text-[9px]"></span>
</button>
{/* Dropdown menu */}
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all absolute left-0 mt-2 rounded-md border border-neutral-800 bg-black/95 shadow-xl z-40 min-w-[220px]">
<ul className="py-2 text-sm">
{items.map((cat) => {
@@ -50,13 +82,15 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
return (
<li key={cat.id}>
<Link
href={`${baseHref}/${cat.id}`}
className={[
"block w-full px-3 py-1.5 text-left text-sm transition-colors",
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"
: "text-neutral-300 hover:bg-neutral-800 hover:text-white",
].join(" ")}
: "text-neutral-300 hover:bg-neutral-800 hover:text-white"
}`}
>
{cat.name}
</Link>
@@ -70,11 +104,23 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
};
return (
<div className="max-w-6xl mx-auto w-full"> {/* ← aligns with topnav */}
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm bg-black/95 backdrop-blur-sm">
{/* Primary builder link */}
/**
* Outer container matches TopNav width:
* - max-w-6xl mx-auto w-full ensures consistent alignment across app
*/
<div className="max-w-6xl mx-auto w-full">
{/**
* FLEX STRATEGY:
* - Everything before the RIGHT cluster is "left side"
* - Then a single `ml-auto` cluster pushes everything else to the right
*/}
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm backdrop-blur-sm">
{/* ------------------------------------------------------------------ */}
{/* LEFT SIDE: primary nav */}
{/* ------------------------------------------------------------------ */}
<Link
href={baseHref}
href="/builder"
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
>
Builder
@@ -91,25 +137,62 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
Builds
</Link>
{/* Right → Current filter */}
{currentCategory && (
<div className="ml-auto flex items-center gap-2 text-neutral-400 text-xs">
<span className="uppercase tracking-wide text-[10px]">Viewing</span>
<span className="font-medium text-neutral-100">
{CATEGORIES.find((c) => c.id === currentCategory)?.name ??
currentCategory}
</span>
<Link
href={baseHref}
className="rounded border border-neutral-700 px-2 py-0.5 text-[10px] uppercase tracking-wide hover:bg-neutral-800 hover:text-white"
{/* ------------------------------------------------------------------ */}
{/* RIGHT SIDE: actions + state */}
{/* IMPORTANT: this is the ONLY `ml-auto` in the nav to avoid conflicts */}
{/* ------------------------------------------------------------------ */}
<div className="ml-auto flex items-center gap-3">
{/* Search icon (placeholder action) */}
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/60 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
aria-label="Search (coming soon)"
title="Search (coming soon)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
Clear
</Link>
</div>
)}
<circle cx="11" cy="11" r="6" />
<line x1="16.5" y1="16.5" x2="21" y2="21" />
</svg>
</button>
{/* "Viewing" indicator (only shows when a category is active) */}
{currentCategory && (
<div className="flex items-center gap-2 text-neutral-400 text-xs">
<span className="uppercase tracking-wide text-[10px]">
Viewing
</span>
<span className="font-medium text-neutral-100">
{CATEGORIES.find((c) => c.id === currentCategory)?.name ??
currentCategory}
</span>
{/* Clears category filter -> go back to /parts */}
<Link
href={baseHref}
className="rounded border border-neutral-700 px-2 py-0.5 text-[10px] uppercase tracking-wide hover:bg-neutral-800 hover:text-white"
>
Clear
</Link>
</div>
)}
{/* FUTURE: drop in ThemeToggle, UserMenu, Notifications here */}
{/* <ThemeToggle /> */}
{/* <UserMenu /> */}
</div>
</nav>
</div>
);
}
export default BuilderNav;
export default BuilderNav;
-79
View File
@@ -1,79 +0,0 @@
"use client";
import Link from "next/link";
import type { Category, Part } from "@/types/gunbuilder";
import { PartCard } from "@/components/PartCard";
interface CategoryColumnProps {
category: Category;
parts: Part[];
selectedPartId?: string;
onSelectPart: (partId: string) => void;
}
export function CategoryColumn({
category,
parts,
selectedPartId,
onSelectPart,
}: CategoryColumnProps) {
// Show selected part if available, otherwise show placeholder
const displayedPart = selectedPartId
? parts.find((p) => p.id === selectedPartId)
: null;
return (
<div className="flex flex-col h-full">
<div className="mb-3">
<h2 className="text-xs font-semibold tracking-[0.15em] text-zinc-400 uppercase">
{category.name}
</h2>
</div>
<div className="flex-1 overflow-y-auto pr-1">
{parts.length === 0 && (
<p className="text-xs text-zinc-500">No parts available yet.</p>
)}
{!displayedPart && parts.length > 0 && (
<div className="w-full border border-zinc-700 rounded-md p-3 mb-2 flex items-center justify-between gap-3">
<p className="text-sm text-zinc-500">No part selected</p>
<Link
href={`/gunbuilder/${category.id}`}
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Choose a Part
</Link>
</div>
)}
{displayedPart && (
<PartCard
key={displayedPart.id}
part={displayedPart}
selected={displayedPart.id === selectedPartId}
onSelect={() => onSelectPart(displayedPart.id)}
/>
)}
</div>
{parts.length >= 1 && (
<Link
href={`/gunbuilder/${category.id}`}
className="mt-2 w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
>
View All ({parts.length})
</Link>
)}
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { PriceHistoryPoint } from "@/app/builder/[categoryId]/[partId]/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/[categoryId]/[partId]/data";
import type { RetailerOffer } from "@/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data";
interface RetailersListProps {
retailers: RetailerOffer[];
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form";
export default function SendEmailForm(): JSX.Element {
const [result, setResult] = useState<
ApiResponse<EmailRequest> | { error: string } | null
>(null);
const [submitting, setSubmitting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, []);
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setResult(null);
setShowSuccess(false);
if (successTimerRef.current) clearTimeout(successTimerRef.current);
const form = new FormData(e.currentTarget);
const recipient = String(form.get("recipient") ?? "");
const subject = String(form.get("subject") ?? "");
const body = String(form.get("body") ?? "");
try {
setSubmitting(true);
const data = await sendEmailAction({ recipient, subject, body });
setResult(data);
if (data?.success === true) {
setShowSuccess(true);
successTimerRef.current = setTimeout(() => {
setShowSuccess(false);
setResult(null); // clear the response panel too
}, 2000);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setResult({ error: message });
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
<h1 className="text-2xl font-semibold tracking-tight">
Send <span className="text-amber-300">Email</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
Compose a message and send it from the admin panel.
</p>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
{result && "error" in result && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{result.error}
</div>
)}
<Field label="Recipient" htmlFor="recipient">
<Input
id="recipient"
name="recipient"
type="email"
placeholder="user@example.com"
defaultValue="user@example.com"
required
/>
</Field>
<Field label="Subject" htmlFor="subject">
<Input
id="subject"
name="subject"
placeholder="Test subject"
defaultValue="Test subject"
required
/>
</Field>
<Field label="Body" htmlFor="body">
<Textarea
id="body"
name="body"
placeholder="Write your message…"
defaultValue="Test body"
rows={6}
required
/>
</Field>
<Button type="submit" disabled={submitting}>
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
</Button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
<div className="text-xs font-medium text-zinc-400">Response</div>
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
{result ? JSON.stringify(result, null, 2) : "No response yet."}
</pre>
</div>
</form>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
import { Button, Field, Input } from "@/components/ui/form";
import RichTextEditor from "@/components/ui/RichTextEditor";
export default function SendEmailForm(): JSX.Element {
const [result, setResult] = useState<
ApiResponse<EmailRequest> | { error: string } | null
>(null);
const [submitting, setSubmitting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [bodyHtml, setBodyHtml] = useState<string>("");
useEffect(() => {
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, []);
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setResult(null);
setShowSuccess(false);
if (successTimerRef.current) clearTimeout(successTimerRef.current);
const form = new FormData(e.currentTarget);
const recipient = String(form.get("recipient") ?? "");
const subject = String(form.get("subject") ?? "");
const body = String(form.get("body") ?? ""); // HTML from hidden input
try {
setSubmitting(true);
const data = await sendEmailAction({ recipient, subject, body });
setResult(data);
if (data?.success === true) {
setShowSuccess(true);
successTimerRef.current = setTimeout(() => {
setShowSuccess(false);
setResult(null);
}, 2000);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setResult({ error: message });
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
<h1 className="text-2xl font-semibold tracking-tight">
Send <span className="text-amber-300">Email</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
Compose a message and send it from the admin panel.
</p>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
{result && "error" in result && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{result.error}
</div>
)}
<Field label="Recipient" htmlFor="recipient">
<Input
id="recipient"
name="recipient"
type="email"
placeholder="user@example.com"
defaultValue="user@example.com"
required
/>
</Field>
<Field label="Subject" htmlFor="subject">
<Input
id="subject"
name="subject"
placeholder="Subject…"
defaultValue="Test subject"
required
/>
</Field>
<Field
label="Body"
htmlFor="body"
hint="This will be sent as HTML."
>
{/* Hidden field so your existing FormData submit keeps working */}
<input type="hidden" id="body" name="body" value={bodyHtml} />
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
placeholder="Write your message…"
className="rounded-md border border-zinc-800 bg-zinc-950/60"
/>
</Field>
<Button type="submit" disabled={submitting}>
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
</Button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
<div className="text-xs font-medium text-zinc-400">Response</div>
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
{result ? JSON.stringify(result, null, 2) : "No response yet."}
</pre>
</div>
</form>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { Sun, Moon } from "lucide-react";
type Theme = "light" | "dark";
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("dark");
const [mounted, setMounted] = useState(false);
useEffect(() => {
const stored = (localStorage.getItem("theme") as Theme | null) ?? "dark";
setTheme(stored);
setMounted(true);
}, []);
if (!mounted) return null; // prevents hydration mismatch
function toggle() {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.toggle("dark", next === "dark");
}
/**
* @TODO, the toggle doesn't look right, need to either fix or remove the toggle.
*/
return (
<button
type="button"
onClick={toggle}
aria-label="Toggle theme" title={`Toggle to ${theme === 'dark' ? 'light' : 'dark'}`}
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 transition-colors
dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-200
border-zinc-300 bg-white text-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
{theme === "dark" ? (
<>
<Sun className="h-4 w-4" />
Light
</>
) : (
<>
<Moon className="h-4 w-4" />
Dark
</>
)}
</button>
);
}
+54 -71
View File
@@ -2,92 +2,78 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import ThemeToggle from "@/components/ThemeToggle";
function NavLink({
href,
children,
title
}: {
href: string;
children: React.ReactNode;
title?: string;
}) {
const pathname = usePathname();
const active = pathname === href;
return (
<Link
href={href}
title={title}
className={[
"text-xs font-medium transition-colors",
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
].join(" ")}
>
{children}
</Link>
);
}
export function TopNav() {
const { user, logout, loading } = useAuth();
const isAuthenticated = !!user;
return (
<header className="border-b border-zinc-800 bg-black/95 backdrop-blur">
{/* Early access bar */}
<div className="border-b border-amber-500/20 bg-amber-500/5">
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
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,
pricing, and available parts are still evolving.
</span>
</div>
<span className="text-amber-100/90 md:hidden">
Early-access prototype. Data and pricing may change.
</span>
</div>
</div>
{/* Main nav row */}
<header className="border-b border-zinc-200 bg-white/95 dark:border-zinc-800 dark:bg-black/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
{/* Left: Brand / Home */}
<Link
href="/"
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
>
Battl Builder
<Image
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo"
width={260}
height={48}
className="block"
/>
</Link>
{/* Center: main nav (Builder / Admin) */}
<nav className="hidden items-center gap-4 md:flex">
<Link
href="/gunbuilder"
className="text-xs font-medium text-zinc-300 hover:text-zinc-50 transition-colors"
>
Builder
</Link>
{isAuthenticated && (
<Link
href="/admin"
className="text-xs font-medium text-emerald-400 hover:text-emerald-300 transition-colors"
>
Admin
</Link>
)}
</nav>
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Search placeholder */}
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/60 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
aria-label="Search (coming soon)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<circle cx="11" cy="11" r="6" />
<line x1="16.5" y1="16.5" x2="21" y2="21" />
</svg>
</button>
<div className="flex items-center gap-4">
<ThemeToggle />
{loading ? (
<span className="text-xs text-zinc-500">Checking session</span>
) : isAuthenticated ? (
<>
<span className="hidden text-xs text-zinc-300 sm:inline">
<NavLink href="/vault" title={"Your builds"}>Vault</NavLink>
{/* Email/displayName links to Account */}
<Link
href="/account"
className="hidden text-xs text-zinc-300 hover:text-zinc-100 transition-colors sm:inline"
title="Account"
>
{user.displayName || user.email}
</span>
</Link>
<button
type="button"
onClick={logout}
@@ -98,12 +84,8 @@ export function TopNav() {
</>
) : (
<>
<Link
href="/login"
className="text-xs font-medium text-zinc-400 hover:text-zinc-100 transition-colors"
>
Log In
</Link>
<NavLink href="/login">Log In</NavLink>
<Link
href="/register"
className="rounded-md border border-amber-400/70 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-200 hover:bg-amber-400/20 transition-colors"
@@ -116,4 +98,5 @@ export function TopNav() {
</div>
</header>
);
}
}
export default TopNav;
+34
View File
@@ -0,0 +1,34 @@
// /components/builder/OverlapChip.tsx
"use client";
import { useEffect, useState } from "react";
export default function OverlapChip(props: {
tone?: "warning" | "info";
label: string;
detail?: string;
}) {
const { tone = "info", label, detail } = props;
const [ready, setReady] = useState(false);
useEffect(() => {
const id = window.setTimeout(() => setReady(true), 10);
return () => window.clearTimeout(id);
}, []);
const base =
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] leading-4 transition-all duration-200";
const pop = ready ? "opacity-100 scale-100" : "opacity-0 scale-95";
const toneCls =
tone === "warning"
? "border-amber-400/40 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-200";
return (
<span className={`${base} ${toneCls} ${pop}`} title={detail ?? label}>
<span className="font-medium">{label}</span>
{detail ? <span className="text-zinc-400">· {detail}</span> : null}
</span>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import React from "react";
export default function Filters(props: {
partRoleLabel: string;
availableBrands: string[];
brandFilter: string[];
setBrandFilter: (v: string[]) => void;
priceBounds: { min: number | null; max: number | null };
priceRange: { min: number | null; max: number | null };
setPriceRange: (v: { min: number | null; max: number | null }) => void;
inStockOnly: boolean;
setInStockOnly: (v: boolean) => void;
}) {
const {
availableBrands,
brandFilter,
setBrandFilter,
priceBounds,
priceRange,
setPriceRange,
inStockOnly,
setInStockOnly,
} = props;
return (
<aside className="w-full rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the results.
</p>
</div>
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{(priceRange.min !== null || priceRange.max !== null) && (
<button
type="button"
onClick={() =>
setPriceRange({
min: priceBounds.min,
max: priceBounds.max,
})
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Reset
</button>
)}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">No price data available.</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange({
min: Math.min(value, priceRange.max ?? priceBounds.max ?? value),
max: priceRange.max ?? priceBounds.max ?? value,
});
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange({
min: priceRange.min ?? priceBounds.min ?? value,
max: Math.max(value, priceRange.min ?? priceBounds.min ?? value),
});
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">No brands available yet.</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter(
e.target.checked
? [...brandFilter, b]
: brandFilter.filter((x) => x !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</aside>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useId, useState } from "react";
export default function OverlapChip(props: {
label: string;
tooltip?: string;
/** stable key so chip doesn't re-animate on trivial rerenders */
persistKey?: string;
className?: string;
}) {
const { label, tooltip, persistKey, className } = props;
// Animate only once per persistKey per session
const storageKey = persistKey ? `bb_overlapchip_seen:${persistKey}` : null;
const [animateIn, setAnimateIn] = useState(false);
const tooltipId = useId();
useEffect(() => {
if (!storageKey) {
// No key provided: animate once on mount
setAnimateIn(true);
return;
}
try {
const seen = sessionStorage.getItem(storageKey);
if (!seen) {
sessionStorage.setItem(storageKey, "1");
setAnimateIn(true);
}
} catch {
// sessionStorage blocked — still animate
setAnimateIn(true);
}
}, [storageKey]);
return (
<span className={`inline-flex items-center ${className ?? ""}`}>
<span
className={[
"inline-flex items-center gap-1.5 rounded-full border border-amber-400/50 bg-amber-400/10 px-2 py-1",
"text-[0.7rem] font-medium text-amber-200",
"transition-all duration-300 ease-out",
animateIn ? "opacity-100 translate-y-0" : "opacity-0 translate-y-1",
].join(" ")}
aria-describedby={tooltip ? tooltipId : undefined}
title={tooltip}
>
<span aria-hidden="true" className="text-amber-300">
</span>
<span>{label}</span>
</span>
{/* Optional: if you later want a real tooltip instead of title,
you can wire one here using tooltipId. */}
{tooltip ? <span id={tooltipId} className="sr-only">{tooltip}</span> : null}
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
export default function Pagination(props: {
currentPage: number;
totalPages: number;
onPrev: () => void;
onNext: () => void;
}) {
const { currentPage, totalPages, onPrev, onNext } = props;
return (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onPrev}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={onNext}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
);
}
+455
View File
@@ -0,0 +1,455 @@
/**
* PartsBrowseClient
* -----------------------------------------------------------------------------
* Browse shell for parts listing pages.
* Owns:
* - fetching parts from backend
* - filter + sort + pagination state
* - layout + list/card views
*/
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Filters from "@/components/parts/Filters";
import SortBar from "@/components/parts/SortBar";
import PartsGrid from "@/components/parts/PartsGrid";
import Pagination from "@/components/parts/Pagination";
import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
import type { CategoryId } from "@/types/gunbuilder";
import {
PART_ROLE_TO_CATEGORY,
normalizePartRole,
} from "@/lib/catalogMappings";
type ViewMode = "card" | "list";
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
type GunbuilderProductFromApi = {
id: string | number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type UiPart = {
id: string;
name: string;
brand: string;
platform: string;
partRole: string;
price: number;
imageUrl?: string;
buyUrl?: string;
inStock?: boolean;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const PAGE_SIZE = 24;
function normalizeId(id: string | number) {
return typeof id === "number" ? String(id) : id;
}
function toSlug(s: string) {
return (s ?? "")
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "")
.trim();
}
/**
* Canonical product details route:
* /parts/p/[platform]/[partRole]/[productSlug]
*/
function buildDetailHref(platform: string, partRole: string, p: UiPart) {
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRole
)}/${encodeURIComponent(`${p.id}-${slug}`)}`;
}
export default function PartsBrowseClient(props: {
partRole: string;
platform?: string | null; // if null, read from query param or default
title?: string;
subtitle?: string;
}) {
const router = useRouter();
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");
const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState("");
const [priceRange, setPriceRange] = useState<{
min: number | null;
max: number | null;
}>({
min: null,
max: null,
});
const [inStockOnly, setInStockOnly] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
// ----------------------------
// Fetch parts
// ----------------------------
useEffect(() => {
if (!partRole) return;
const controller = new AbortController();
async function fetchParts() {
try {
setLoading(true);
setError(null);
const search = new URLSearchParams();
search.set("platform", effectivePlatform);
search.append("partRoles", partRole);
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();
setParts(
data.map((p) => ({
id: normalizeId(p.id),
name: p.name,
brand: p.brand,
platform: p.platform,
partRole: p.partRole,
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
buyUrl: p.buyUrl ?? undefined,
inStock: p.inStock ?? true,
}))
);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchParts();
return () => controller.abort();
}, [partRole, effectivePlatform]);
// Reset pagination on filters
useEffect(() => {
setCurrentPage(1);
}, [
partRole,
effectivePlatform,
brandFilter,
sortBy,
searchQuery,
priceRange,
inStockOnly,
]);
// Reset scroll on route change
useEffect(() => {
window.scrollTo({ top: 0, behavior: "auto" });
}, [pathname]);
// ----------------------------
// Derived values
// ----------------------------
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
[parts]
);
const priceBounds = useMemo(() => {
if (!parts.length)
return { min: null as number | null, max: null as number | null };
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(() => {
const minBound = priceBounds.min;
const maxBound = priceBounds.max;
if (minBound == null || maxBound == null) return;
setPriceRange((prev) => {
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.min(nextMin, nextMax),
max: Math.max(nextMin, nextMax),
};
});
}, [priceBounds.min, priceBounds.max]);
const filteredParts = useMemo(() => {
let result = [...parts];
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)
result = result.filter((p) => brandFilter.includes(p.brand));
if (inStockOnly) result = result.filter((p) => p.inStock ?? true);
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":
result.sort((a, b) => a.price - b.price);
break;
case "price-desc":
result.sort((a, b) => b.price - a.price);
break;
case "brand-asc":
result.sort((a, b) => a.brand.localeCompare(b.brand));
break;
case "relevance":
default:
break;
}
return result;
}, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE));
const paginatedParts = filteredParts.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
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.";
// ----------------------------
// Add → Builder handoff
// ----------------------------
const handleAddToBuild = (p: UiPart) => {
const normalizedRole = normalizePartRole(partRole);
const categoryId: CategoryId | null =
PART_ROLE_TO_CATEGORY[partRole] ??
PART_ROLE_TO_CATEGORY[normalizedRole] ??
null;
if (!categoryId) {
alert(
`No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.`
);
return;
}
const qp = new URLSearchParams();
qp.set("platform", effectivePlatform);
qp.set("select", `${categoryId}:${p.id}`);
router.push(`/builder?${qp.toString()}`);
};
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 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">
{headingTitle}{" "}
<span className="text-amber-300">{effectivePlatform}</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
{headingSubtitle}
</p>
{!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>
{isBuilderMode && (
<div className="flex items-center gap-2">
<Link
href={`/builder?platform=${encodeURIComponent(
effectivePlatform
)}`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold hover:bg-zinc-800"
>
Back to Build
</Link>
{/* Optional: view toggle in builder-mode too */}
{parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
>
List
</button>
</div>
)}
</div>
)}
</header>
<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}
brandFilter={brandFilter}
setBrandFilter={setBrandFilter}
priceBounds={priceBounds}
priceRange={priceRange}
setPriceRange={setPriceRange}
inStockOnly={inStockOnly}
setInStockOnly={setInStockOnly}
/>
</aside>
)}
<div className="flex-1">
{!loading && !error && (
<SortBar
visibleRange={visibleRange}
totalCount={filteredParts.length}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
sortBy={sortBy}
setSortBy={setSortBy}
/>
)}
{loading ? (
<p className="py-8 text-center text-sm text-zinc-500">
Loading
</p>
) : error ? (
<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.
</p>
) : (
<PartsGrid
viewMode={viewMode}
parts={paginatedParts}
buildDetailHref={(p) =>
buildDetailHref(effectivePlatform, partRole, p)
}
onAddToBuild={handleAddToBuild}
addLabel="Add to Build"
/>
)}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPrev={() => setCurrentPage((p) => Math.max(1, p - 1))}
onNext={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
/>
)}
</div>
</div>
</section>
</div>
</main>
);
}
+173
View File
@@ -0,0 +1,173 @@
"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;
imageUrl?: string;
buyUrl?: string;
inStock?: boolean;
};
export default function PartsGrid(props: {
viewMode: "card" | "list";
parts: UiPart[];
buildDetailHref: (p: UiPart) => string;
onAddToBuild?: (p: UiPart) => void;
addLabel?: string;
}) {
const { viewMode, parts, buildDetailHref, onAddToBuild } = props;
const addLabel = props.addLabel ?? "Add to Build";
if (viewMode === "card") {
return (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{parts.map((part) => (
<div
key={part.id}
className="group relative flex flex-col rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
>
<div className="mb-3 flex items-center 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>
</div>
</div>
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
<div className="mt-2 flex gap-2">
<Link
href={buildDetailHref(part)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
{onAddToBuild ? (
<button
type="button"
onClick={() => onAddToBuild(part)}
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
>
{addLabel}
</button>
) : part.buyUrl ? (
<a
href={part.buyUrl}
target="_blank"
rel="noreferrer"
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
>
Buy
</a>
) : (
<button
disabled
className="flex-1 rounded-md bg-zinc-700 px-3 py-2 text-center text-xs font-semibold text-zinc-200 opacity-50"
>
No Action
</button>
)}
</div>
</div>
))}
</div>
);
}
// LIST view
return (
<>
{/* Header (DESKTOP ONLY) */}
<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>
{/* Rows */}
<div className="space-y-2">
{parts.map((part) => (
<div
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,1fr)_120px_110px_120px] md:items-center md:gap-4">
{/* Part */}
<div className="min-w-0">
<Link
href={buildDetailHref(part)}
className="block text-sm font-semibold text-zinc-50 leading-snug line-clamp-2 break-words hover:text-amber-300 transition-colors"
title={part.name}
>
{part.name}
</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 */}
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
{onAddToBuild ? (
<button
type="button"
onClick={() => onAddToBuild(part)}
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"
>
<Plus className="h-4 w-4" />
</button>
) : part.buyUrl ? (
<a
href={part.buyUrl}
target="_blank"
rel="noreferrer"
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
>
Buy
</a>
) : (
<button
disabled
className="whitespace-nowrap rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 opacity-50"
>
No Action
</button>
)}
</div>
</div>
</div>
))}
</div>
</>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import PlatformSwitcher from "./PlatformSwitcher";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type ProductListDto = {
id: string; // your API returns strings sometimes; keep it flexible
name: string;
brand: string;
platform: string;
partRole: string;
categoryKey: string | null;
price: number | null;
buyUrl: string | null;
imageUrl: string | null;
};
function safeSlugify(input: string) {
return (input ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 80);
}
export default function PartsListPageClient(props: {
platform: string;
partRole: string;
}) {
const { platform, partRole } = props;
const [items, setItems] = useState<ProductListDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Simple client-side search (fast + good enough for now)
const [q, setQ] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
setError(null);
// Your current list endpoint:
// GET /api/v1/products?platform=AR-15&partRoles=upper-receiver
const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`Failed to load products (${res.status}): ${txt}`);
}
const data: ProductListDto[] = await res.json();
if (!cancelled) setItems(data);
} catch (e: any) {
if (!cancelled) setError(e?.message ?? "Failed to load products");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [platform, partRole]);
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return items;
return items.filter((p) => {
const blob = `${p.brand ?? ""} ${p.name ?? ""} ${p.categoryKey ?? ""}`.toLowerCase();
return blob.includes(needle);
});
}, [items, q]);
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 flex flex-col gap-3">
<div className="flex items-center 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">
Parts: <span className="text-amber-300">{partRole}</span>
</h1>
</div>
<PlatformSwitcher currentPlatform={platform} partRole={partRole} />
</div>
<div className="flex items-center gap-3">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search brand, name, category…"
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-amber-400"
/>
</div>
</header>
{loading && <p className="text-sm text-zinc-500">Loading parts</p>}
{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>
)}
{!loading && !error && (
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
Results
</p>
<p className="text-xs text-zinc-400">
{filtered.length} items
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{filtered.map((p) => {
const id = String(p.id);
const slug = safeSlugify(p.name);
const href = `/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${id}-${slug}`;
return (
<Link
key={`${p.id}`}
href={href}
className="group rounded-lg border border-zinc-800 bg-black/30 p-3 hover:border-zinc-700"
>
<div className="flex gap-3">
<div className="h-16 w-16 overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{/* eslint-disable-next-line @next/next/no-img-element */}
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full grid place-items-center text-xs text-zinc-600">
</div>
)}
</div>
<div className="flex-1">
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
{p.brand}
</p>
<p className="mt-1 text-sm font-medium text-zinc-100 group-hover:text-amber-200">
{p.name}
</p>
<div className="mt-2 flex items-center justify-between">
<p className="text-xs text-zinc-500 line-clamp-1">
{p.categoryKey ?? "—"}
</p>
<p className="text-xs font-semibold text-zinc-200">
{typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"}
</p>
</div>
</div>
</div>
</Link>
);
})}
</div>
</section>
)}
</div>
</main>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
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];
export default function PlatformSwitcher(props: {
currentPlatform: string;
partRole: string;
/**
* 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 pathname = usePathname();
const searchParams = useSearchParams();
const inferredMode: "browse" | "detail" = pathname.startsWith("/parts/p/")
? "detail"
: "browse";
const mode = props.mode ?? inferredMode;
const buildHref = (nextPlatform: Platform) => {
if (mode === "detail") {
return `/parts/p/${encodeURIComponent(nextPlatform)}/${encodeURIComponent(
props.partRole
)}`;
}
// browse mode => keep on /parts/[role] and just change the query param
const qp = props.preserveQuery
? new URLSearchParams(searchParams.toString())
: new URLSearchParams();
qp.set("platform", nextPlatform);
return {
pathname: `/parts/${encodeURIComponent(props.partRole)}`,
query: Object.fromEntries(qp.entries()),
} as const;
};
return (
<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>
);
}
@@ -0,0 +1,205 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import PlatformSwitcher from "./PlatformSwitcher";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type ProductDetailDto = {
id: string;
name: string;
brand: string;
platform: string;
partRole: string;
categoryKey: string | null;
price: number | null;
buyUrl: string | null;
imageUrl: string | null;
};
function parseIdFromProductSlug(productSlug: string): string | null {
// Expected: "1217-some-slug"
const m = /^(\d+)(?:-|$)/.exec(productSlug ?? "");
return m?.[1] ?? null;
}
/**
* Attempts to fetch a product detail.
* If you don't have a dedicated endpoint yet, it falls back to list + filter by id.
*/
async function fetchProductDetail(params: {
platform: string;
partRole: string;
productSlug: string;
}): Promise<ProductDetailDto> {
const { platform, partRole, productSlug } = params;
const id = parseIdFromProductSlug(productSlug);
if (!id) throw new Error(`Invalid product URL slug: '${productSlug}' (could not parse id)`);
// ---- Preferred (if you add it): GET /api/v1/products/{id}
// If it 404s, we fall back.
try {
const res = await fetch(`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" },
});
if (res.ok) {
return await res.json();
}
} catch {
// ignore and fall back
}
// ---- Fallback: use list endpoint + find by id (works right now with your current API)
const listRes = await fetch(
`${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
{ headers: { Accept: "application/json" } }
);
if (!listRes.ok) {
const txt = await listRes.text().catch(() => "");
throw new Error(`Failed to load product (fallback) (${listRes.status}): ${txt}`);
}
const list: ProductDetailDto[] = await listRes.json();
const found = list.find((p) => String(p.id) === String(id));
if (!found) {
throw new Error(`Product id=${id} not found in ${platform}/${partRole} list`);
}
return found;
}
export default function ProductDetailPageClient(props: {
platform: string;
partRole: string;
productSlug: string;
}) {
const { platform, partRole, productSlug } = props;
const [p, setP] = useState<ProductDetailDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const id = useMemo(() => parseIdFromProductSlug(productSlug), [productSlug]);
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
setError(null);
const data = await fetchProductDetail({ platform, partRole, productSlug });
if (!cancelled) setP(data);
} catch (e: any) {
if (!cancelled) setError(e?.message ?? "Failed to load product");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [platform, partRole, productSlug]);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
<header className="mb-6 flex flex-col gap-3">
<div className="flex items-center 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">
Part: <span className="text-amber-300">{partRole}</span>
</h1>
</div>
{/* 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/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}>
Back to list
</Link>
<span></span>
<span>id: {id ?? "—"}</span>
</div>
</header>
{loading && <p className="text-sm text-zinc-500">Loading product</p>}
{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>
)}
{!loading && !error && p && (
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 md:p-6">
<div className="flex flex-col gap-6 md:flex-row">
<div className="w-full md:w-72">
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full grid place-items-center text-sm text-zinc-600">
No image
</div>
)}
</div>
</div>
<div className="flex-1">
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
{p.brand} {p.platform}
</p>
<h2 className="mt-2 text-xl md:text-2xl font-semibold text-zinc-100">
{p.name}
</h2>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm">
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Category</p>
<p className="mt-1 text-zinc-200">{p.categoryKey ?? "—"}</p>
</div>
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Best price</p>
<p className="mt-1 text-zinc-200">
{typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"}
</p>
</div>
</div>
<div className="mt-5 flex items-center gap-3">
{p.buyUrl ? (
<a
href={p.buyUrl}
target="_blank"
rel="noreferrer"
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300"
>
Buy / View offer
</a>
) : (
<span className="text-sm text-zinc-500">No buy link</span>
)}
</div>
</div>
</div>
</section>
)}
</div>
</main>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
export default function SortBar(props: {
visibleRange: { start: number; end: number };
totalCount: number;
searchQuery: string;
setSearchQuery: (v: string) => void;
sortBy: SortOption;
setSortBy: (v: SortOption) => void;
}) {
const { visibleRange, totalCount, searchQuery, setSearchQuery, sortBy, setSortBy } = props;
return (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{totalCount}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label htmlFor="part-search" className="text-xs text-zinc-500">
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label htmlFor="sort-by" className="text-xs text-zinc-500">
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
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"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import QuillImageResize from "quill-image-resize-module";
Quill.register("modules/imageResize", QuillImageResize);
import dynamic from "next/dynamic";
import type React from "react";
import "react-quill/dist/quill.snow.css";
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
type RichTextEditorProps = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
className?: string;
};
export default function RichTextEditor({
value,
onChange,
placeholder,
className,
}: RichTextEditorProps) {
return (
<div className={className}>
<div className="bb-quill">
<ReactQuill
theme="snow"
value={value}
onChange={onChange}
placeholder={placeholder}
modules={{
toolbar: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
[{ color: [] }, { background: [] }],
[{ list: "ordered" }, { list: "bullet" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
}}
/>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
// components/ui/form.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/cn";
export const labelClass = "text-xs font-medium text-zinc-400";
export const inputBaseClass =
"w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 " +
"placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-amber-400";
export const buttonBaseClass =
"w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 " +
"hover:bg-amber-400/30 disabled:cursor-not-allowed disabled:opacity-50 transition-colors";
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(function Input({ className, ...props }, ref) {
return <input ref={ref} className={cn(inputBaseClass, className)} {...props} />;
});
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(function Textarea({ className, ...props }, ref) {
return (
<textarea ref={ref} className={cn(inputBaseClass, className)} {...props} />
);
});
export function Button({
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return <button className={cn(buttonBaseClass, className)} {...props} />;
}
type FieldProps = {
label: string;
htmlFor: string;
hint?: React.ReactNode;
error?: React.ReactNode;
className?: string;
children: React.ReactNode;
};
export function Field({
label,
htmlFor,
hint,
error,
className,
children,
}: FieldProps) {
return (
<div className={cn("space-y-1", className)}>
<label className={labelClass} htmlFor={htmlFor}>
{label}
</label>
{children}
{error ? (
<p className="text-xs text-red-300">{error}</p>
) : hint ? (
<p className="text-xs text-zinc-500">{hint}</p>
) : null}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
import { Button, Field, Input } from "@/components/ui/form";
import RichTextEditor from "@/components/ui/RichTextEditor";
export default function SendEmailForm(): JSX.Element {
const [result, setResult] = useState<
ApiResponse<EmailRequest> | { error: string } | null
>(null);
const [submitting, setSubmitting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [bodyHtml, setBodyHtml] = useState<string>("");
useEffect(() => {
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, []);
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setResult(null);
setShowSuccess(false);
if (successTimerRef.current) clearTimeout(successTimerRef.current);
const form = new FormData(e.currentTarget);
const recipient = String(form.get("recipient") ?? "");
const subject = String(form.get("subject") ?? "");
const body = String(form.get("body") ?? ""); // HTML from hidden input
try {
setSubmitting(true);
const data = await sendEmailAction({ recipient, subject, body });
setResult(data);
if (data?.success === true) {
setShowSuccess(true);
successTimerRef.current = setTimeout(() => {
setShowSuccess(false);
setResult(null);
}, 2000);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setResult({ error: message });
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
<h1 className="text-2xl font-semibold tracking-tight">
Send <span className="text-amber-300">Email</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
Compose a message and send it from the admin panel.
</p>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
{result && "error" in result && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{result.error}
</div>
)}
<Field label="Recipient" htmlFor="recipient">
<Input
id="recipient"
name="recipient"
type="email"
placeholder="user@example.com"
defaultValue="user@example.com"
required
/>
</Field>
<Field label="Subject" htmlFor="subject">
<Input
id="subject"
name="subject"
placeholder="Subject…"
defaultValue="Test subject"
required
/>
</Field>
<Field
label="Body"
htmlFor="body"
hint="This will be sent as HTML."
>
{/* Hidden field so your existing FormData submit keeps working */}
<input type="hidden" id="body" name="body" value={bodyHtml} />
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
placeholder="Write your message…"
className="rounded-md border border-zinc-800 bg-zinc-950/60"
/>
</Field>
<Button type="submit" disabled={submitting}>
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
</Button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
<div className="text-xs font-medium text-zinc-400">Response</div>
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
{result ? JSON.stringify(result, null, 2) : "No response yet."}
</pre>
</div>
</form>
</div>
);
}
+82 -35
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,22 +49,56 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true);
// Hydrate from localStorage on first load
/**
* Persist auth to localStorage
*/
const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return;
if (nextToken) {
window.localStorage.setItem(TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (nextUser) {
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else {
window.localStorage.removeItem(USER_KEY);
}
},
[]
);
/**
* ✅ Hydrate from localStorage ONCE
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
*/
useEffect(() => {
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) {
if (storedToken && storedUser) {
try {
setUser(JSON.parse(storedUser));
const parsedUser = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser);
// ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: storedToken,
role: parsedUser?.role,
}),
}).catch(() => {});
} catch {
// bad JSON? wipe it
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
}
}
@@ -65,27 +106,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false);
}, []);
const persistAuth = useCallback((nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return;
/**
* Used by login/register/magic-link
*/
const setSession = useCallback(
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
if (nextToken) {
window.localStorage.setItem(TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (nextUser) {
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else {
window.localStorage.removeItem(USER_KEY);
}
}, []);
// ✅ 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);
try {
const res = await fetch(`${API_BASE_URL}/auth/login`, {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
@@ -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(
@@ -130,7 +175,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/auth/register`, {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, displayName }),
@@ -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,16 +227,16 @@ 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) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}
}
+263
View File
@@ -0,0 +1,263 @@
// data/builderSlots.ts
/**
* Builder "slots" represent the major assemblies in a Battl Builder rifle:
* - Lower Receiver Assembly
* - Upper Receiver Assembly
* - Fire Control / Optics / Accessories, etc.
*
* Each slot can be satisfied by one or more "patterns" of categories.
* Example:
* Lower Assembly is satisfied by EITHER:
* - a single Complete Lower
* - OR a combination of stripped lower + parts + buffer + stock + grip + safety
*
* The frontend can use this file to:
* - show build progress for each slot
* - grey out / collapse sub-categories when a complete assembly is selected
* - validate that a build is "complete" before sharing/saving
*/
export type SlotGroup = "LOWER" | "UPPER" | "SYSTEM" | "ACCESSORIES";
/**
* CategoryId should correspond to CATEGORIES[id] from data/gunbuilderParts.ts
* We keep it loose here to avoid circular type imports.
*/
export type CategoryId = string;
export type BuilderSlotId =
| "lowerAssembly"
| "upperAssembly"
| "opticSystem"
| "sightSystem"
| "suppressorSystem"
| "magazineSystem"
| "accessoriesSupport";
export interface BuilderSlot {
id: BuilderSlotId;
label: string;
group: SlotGroup;
/**
* Human-facing description for tooltips / help text.
*/
description: string;
/**
* All category IDs that are visually associated with this slot.
* (Useful for layout / grouping in the UI.)
*/
categories: CategoryId[];
/**
* Each inner array is a valid way to "satisfy" the slot.
*
* Example (lowerAssembly):
* [
* ["completeLower"], // Pattern 1: single complete lower
* ["lower", "lowerParts", "trigger", "grip", "safety", "buffer", "stock"] // Pattern 2: custom lower
* ]
*/
satisfactionPatterns: CategoryId[][];
}
/**
* Core Battl Builder slots.
*
* These line up with your existing CATEGORIES in data/gunbuilderParts.ts:
*
* LOWER:
* - lower
* - completeLower
* - lowerParts
* - trigger
* - grip
* - safety
* - buffer
* - stock
*
* UPPER:
* - upper
* - completeUpper
* - bcg
* - barrel
* - gasBlock
* - gasTube
* - muzzleDevice
* - suppressor
* - handguard
* - chargingHandle
* - sights
* - optic
*
* ACCESSORIES:
* - magazine
* - weaponLight
* - foregrip
* - bipod
* - sling
* - railAccessory
* - tools
*/
export const BUILDER_SLOTS: BuilderSlot[] = [
{
id: "lowerAssembly",
label: "Lower Receiver Assembly",
group: "LOWER",
description:
"The serialized foundation of the rifle: receiver, controls, fire control, stock, and buffer system.",
categories: [
"lower",
"completeLower",
"lowerParts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
],
satisfactionPatterns: [
// Pattern 1: single complete lower does it all
["completeLower"],
// Pattern 2: fully custom lower built from individual parts
["lower", "lowerParts", "trigger", "grip", "safety", "buffer", "stock"],
],
},
{
id: "upperAssembly",
label: "Upper Receiver Assembly",
group: "UPPER",
description:
"The working end of the rifle: upper, barrel, gas system, handguard, BCG, charging handle, and muzzle device.",
categories: [
"upper",
"completeUpper",
"bcg",
"barrel",
"gasBlock",
"gasTube",
"handguard",
"muzzleDevice",
"chargingHandle",
],
satisfactionPatterns: [
// Pattern 1: complete upper
["completeUpper"],
// Pattern 2: custom upper from parts
[
"upper",
"barrel",
"gasBlock",
"gasTube",
"handguard",
"bcg",
"chargingHandle",
"muzzleDevice",
],
],
},
{
id: "opticSystem",
label: "Optic System",
group: "SYSTEM",
description:
"Primary glass for the rifle: LPVOs, red dots, prisms, and magnifiers.",
categories: ["optic"],
satisfactionPatterns: [
// For now: any optic fills the slot.
["optic"],
],
},
{
id: "sightSystem",
label: "Backup / Iron Sights",
group: "SYSTEM",
description:
"Backup irons or primary sights for no-glass builds and hard-use setups.",
categories: ["sights"],
satisfactionPatterns: [["sights"]],
},
{
id: "suppressorSystem",
label: "Muzzle / Suppressor",
group: "SYSTEM",
description:
"Dedicated suppressor slot for QD or direct-thread cans. (Muzzle device is handled in the upper assembly patterns.)",
categories: ["suppressor"],
satisfactionPatterns: [["suppressor"]],
},
{
id: "magazineSystem",
label: "Magazines",
group: "ACCESSORIES",
description:
"Feeding the beast: mags appropriate to the platform and caliber.",
categories: ["magazine"],
satisfactionPatterns: [["magazine"]],
},
{
id: "accessoriesSupport",
label: "Support Gear",
group: "ACCESSORIES",
description:
"Lights, lasers, foregrips, bipods, slings, rail widgets, and tools that keep the rifle honest.",
categories: [
"weaponLight",
"foregrip",
"bipod",
"sling",
"railAccessory",
"tools",
],
// Accessories are all optional; this slot is never strictly "required"
satisfactionPatterns: [
// An empty pattern would technically always be satisfied,
// but we can treat this slot as "nice to have" in the UI instead.
["weaponLight"],
["foregrip"],
["bipod"],
["sling"],
["railAccessory"],
["tools"],
],
},
];
/**
* Helper: determine whether a slot is satisfied given currently selected parts.
*
* `selectedByCategory` is expected to be a map of categoryId → truthy if selected.
* The value can be the actual Part, or just `true` if you track it elsewhere.
*/
export function isSlotSatisfied(
slot: BuilderSlot,
selectedByCategory: Record<CategoryId, unknown>
): boolean {
return slot.satisfactionPatterns.some((pattern) =>
pattern.every((categoryId) => Boolean(selectedByCategory[categoryId]))
);
}
/**
* Optional helper: get the first pattern that is currently satisfied.
* Can be used to:
* - show which "path" the user took (complete vs custom)
* - conditionally collapse/expand sub-categories in the UI
*/
export function getSatisfiedPattern(
slot: BuilderSlot,
selectedByCategory: Record<CategoryId, unknown>
): CategoryId[] | null {
for (const pattern of slot.satisfactionPatterns) {
const ok = pattern.every((categoryId) =>
Boolean(selectedByCategory[categoryId])
);
if (ok) return pattern;
}
return null;
}
+44 -145
View File
@@ -1,163 +1,62 @@
import type { Category, Part } from "@/types/gunbuilder";
import type { Category } from "@/types/gunbuilder";
import { CATEGORY_SLUGS } from "@/types/gunbuilder";
export const CATEGORIES: Category[] = [
// LOWER
// Lower group
{
id: "lower",
name: "Stripped Lowers",
description: "Serialized lower the foundation of the build.",
id: "lower-receiver",
name: "Stripped Lower",
group: "lower",
slug: CATEGORY_SLUGS["lower-receiver"],
},
{
id: "completeLower",
name: "Complete Lowers",
description: "Factory-built lower with stock, buffer system, and controls.",
id: "complete-lower",
name: "Complete Lower",
group: "lower",
slug: CATEGORY_SLUGS["complete-lower"],
},
{
id: "lowerParts",
name: "Lower Parts Kits",
description: "Pins, springs, and controls for the lower.",
group: "lower",
},
{
id: "trigger",
name: "Trigger / Fire Control",
description: "Single-stage, two-stage, and match triggers.",
group: "lower",
},
{
id: "grip",
name: "Pistol Grip",
description: "Grips that shape how the rifle handles.",
group: "lower",
},
{
id: "safety",
name: "Safety / Selector",
description: "Ambi selectors and control upgrades.",
group: "lower",
},
{
id: "buffer",
name: "Buffer System",
description: "Buffer tube, buffer, spring, and related parts.",
group: "lower",
},
{
id: "stock",
name: "Stock / Brace",
description: "Adjustable, fixed, and minimalist stocks.",
id: "lower-parts",
name: "Lower Parts Kit",
group: "lower",
slug: CATEGORY_SLUGS["lower-parts"],
},
{ id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
{ id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
{ id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
{ id: "buffer", name: "Buffer System", group: "lower", slug: CATEGORY_SLUGS.buffer },
{ id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
// UPPER
// Upper group
{
id: "upper",
name: "Stripped Uppers",
description: "The core of the top half of your build.",
id: "upper-receiver",
name: "Stripped Upper",
group: "upper",
slug: CATEGORY_SLUGS["upper-receiver"],
},
{
id: "completeUpper",
name: "Complete Uppers",
description: "Pre-assembled uppers ready to pin and shoot.",
id: "complete-upper",
name: "Complete Upper",
group: "upper",
slug: CATEGORY_SLUGS["complete-upper"],
},
{
id: "bcg",
name: "Bolt Carriers",
description: "The heartbeat of the rifles cycling.",
group: "upper",
},
{
id: "barrel",
name: "Barrels",
description: "Different lengths, profiles, and calibers.",
group: "upper",
},
{
id: "gasBlock",
name: "Gas Blocks",
description: "Standard and adjustable gas blocks.",
group: "upper",
},
{
id: "gasTube",
name: "Gas Tubes",
description: "Carbine, mid, rifle, and more.",
group: "upper",
},
{
id: "muzzleDevice",
name: "Muzzle Devices",
description: "Brakes, comps, and flash hiders.",
group: "upper",
},
{
id: "suppressor",
name: "Suppressors",
description: "Hearing-safe setups and hosts.",
group: "upper",
},
{
id: "handguard",
name: "Handguards / Rails",
description: "M-LOK rails and front-end furniture.",
group: "upper",
},
{
id: "chargingHandle",
name: "Charging Handles",
description: "Standard and ambi charging handles.",
group: "upper",
},
{
id: "sights",
name: "Iron Sights",
description: "Backup and primary irons.",
group: "upper",
},
{
id: "optic",
name: "Optics",
description: "LPVOs, red dots, and magnifiers.",
group: "upper",
},
// ACCESSORIES GROUP
{
id: "magazine",
name: "Magazines",
group: "accessories",
},
{
id: "weaponLight",
name: "Weapon Lights & Lasers",
group: "accessories",
},
{
id: "foregrip",
name: "Vertical / Angled Grips",
group: "accessories",
},
{
id: "bipod",
name: "Bipods",
group: "accessories",
},
{
id: "sling",
name: "Slings & Mounts",
group: "accessories",
},
{
id: "railAccessory",
name: "Rail Sections & Covers",
group: "accessories",
},
{
id: "tools",
name: "Tools & Maintenance",
group: "accessories",
},
];
{ id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
{ id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
{ id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
{ id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] },
{ id: "muzzle-device", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS["muzzle-device"] },
{ id: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights },
{ id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg },
{ id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] },
{ id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor },
{ id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic },
// Accessories (platform-agnostic)
{ id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine },
{ id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] },
{ id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip },
{ id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod },
{ id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling },
{ id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
{ id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
];
-102
View File
@@ -1,102 +0,0 @@
import type { CategoryId } from "@/types/gunbuilder";
// CategoryId -> partRoles
export const CATEGORY_TO_PART_ROLES: Record<CategoryId, string[]> = {
// CORE / EXISTING
upper: ["upper-receiver"],
barrel: ["barrel"],
handguard: ["handguard"],
chargingHandle: ["charging-handle"],
buffer: ["buffer-kit"],
lowerParts: ["lower-parts-kit"],
sights: ["sight"],
lower: ["lower-receiver"],
optic: ["optic"],
stock: ["stock"],
// NEW LOWER PARTS
trigger: ["trigger", "trigger-kit"],
grip: ["pistol-grip", "grip"],
safety: ["safety", "safety-selector"],
completeLower: ["complete-lower"],
// NEW UPPER PARTS
completeUpper: ["complete-upper"],
bcg: ["bcg", "bolt-carrier-group"],
gasBlock: ["gas-block"],
gasTube: ["gas-tube"],
muzzleDevice: ["muzzle-device", "compensator", "brake"],
suppressor: ["suppressor"],
// ===== ACCESSORIES =====
// Magazines
magazine: [
"magazine",
"mag",
"magazine-ar15",
"magazine-308",
"drum-magazine",
],
// Lights / Lasers
weaponLight: [
"weapon-light",
"light",
"weapon-light-laser",
"light-laser-combo",
"laser",
],
// Foregrips
foregrip: [
"vertical-grip",
"angled-foregrip",
"foregrip",
"handstop",
],
// Bipods
bipod: [
"bipod",
],
// Slings & mounts
sling: [
"sling",
"sling-mount",
"sling-swivel",
"qd-sling-mount",
],
// Rail sections & covers
railAccessory: [
"rail-section",
"picatinny-rail-section",
"m-lok-rail-section",
"keymod-rail-section",
"rail-cover",
"rail-panel",
],
// Tools & maintenance
tools: [
"tool",
"armorer-tool",
"armorer-wrench",
"cleaning-kit",
"bore-snake",
"vise-block",
"torque-wrench",
],
};
// Invert to get partRole -> CategoryId
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries(
CATEGORY_TO_PART_ROLES,
).reduce((acc, [categoryId, roles]) => {
for (const role of roles) {
acc[role] = categoryId as CategoryId;
}
return acc;
}, {} as Record<string, CategoryId>);
+55 -18
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 post(path: string, body: any) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(body),
});
}
async function get(path: string, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
...init,
headers: authHeaders(init?.headers),
});
}
return { get, post };
async function post(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "POST",
...init,
headers: authHeaders({
"Content-Type": "application/json",
...(init?.headers as any),
}),
body: JSON.stringify(body),
});
}
async function put(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "PUT",
...init,
headers: authHeaders({
"Content-Type": "application/json",
...(init?.headers as any),
}),
body: JSON.stringify(body),
});
}
async function del(path: string, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, {
method: "DELETE",
...init,
headers: authHeaders(init?.headers),
});
}
// ✅ stable reference unless token changes
return { get, post, put, del };
}, [token]);
}
View File
+81
View File
@@ -0,0 +1,81 @@
export interface EmailRequest {
id: number;
recipient: string;
email: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface CreateEmailRequestDto {
email: string;
status?: string;
}
export interface UpdateEmailRequestDto {
email?: string;
status?: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
export async function fetchEmailRequests(token: string): Promise<EmailRequest[]> {
const response = await fetch(`${API_BASE}/api/email`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch email requests: ${response.statusText}`);
}
return response.json();
}
export async function createEmailRequest(token: string, data: CreateEmailRequestDto): Promise<EmailRequest> {
const response = await fetch(`${API_BASE}/api/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to create email request: ${response.statusText}`);
}
return response.json();
}
export async function updateEmailRequest(token: string, id: number, data: UpdateEmailRequestDto): Promise<EmailRequest> {
const response = await fetch(`${API_BASE}/api/email/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to update email request: ${response.statusText}`);
}
return response.json();
}
export async function deleteEmailRequest(token: string, id: number): Promise<void> {
const response = await fetch(`${API_BASE}/api/email/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to delete email request: ${response.statusText}`);
}
}
+103
View File
@@ -0,0 +1,103 @@
// lib/api/platforms.ts
export type Platform = {
id: number;
key: string;
label: string;
isActive: boolean;
};
export type CreatePlatformDto = {
key: string;
label: string;
isActive?: boolean;
};
export type UpdatePlatformDto = Partial<CreatePlatformDto>;
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
function normalizePlatform(raw: any): Platform {
const id = Number(raw?.id);
const key = String(raw?.key ?? "").trim();
const label = String(raw?.label ?? "").trim();
const activeRaw = raw?.isActive ?? raw?.is_active ?? true;
const isActive =
typeof activeRaw === "boolean"
? activeRaw
: typeof activeRaw === "number"
? activeRaw === 1
: String(activeRaw).toLowerCase() === "true";
return { id, key, label, isActive };
}
export async function fetchPlatforms(tokenHeaders: HeadersInit): Promise<Platform[]> {
const res = await fetch(`${API_BASE}/api/platforms`, {
headers: { ...tokenHeaders },
});
if (!res.ok) {
throw new Error(`Failed to load platforms (${res.status})`);
}
const data = (await res.json()) as unknown;
const items = Array.isArray(data) ? data : [];
return items.map(normalizePlatform).filter((p) => Number.isFinite(p.id) && p.key && p.label);
}
export async function createPlatform(
tokenHeaders: HeadersInit,
dto: CreatePlatformDto
): Promise<Platform> {
const res = await fetch(`${API_BASE}/api/platforms`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...tokenHeaders,
},
body: JSON.stringify(dto),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Failed to create platform (${res.status})`);
}
return normalizePlatform(await res.json());
}
export async function updatePlatform(
tokenHeaders: HeadersInit,
id: number,
dto: UpdatePlatformDto
): Promise<Platform> {
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
...tokenHeaders,
},
body: JSON.stringify(dto),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Failed to update platform (${res.status})`);
}
return normalizePlatform(await res.json());
}
export async function deletePlatform(tokenHeaders: HeadersInit, id: number): Promise<void> {
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
method: "DELETE",
headers: { ...tokenHeaders },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Failed to delete platform (${res.status})`);
}
}
+150
View File
@@ -0,0 +1,150 @@
// /lib/buildOverlaps.ts
import type { CategoryId } from "@/types/gunbuilder";
export type OverlapChipModel = {
key: string;
tone: "warning" | "info";
label: string;
detail?: string;
};
export const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
export const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
export const UPPER_OVERLAP_CATEGORIES = new Set<CategoryId>([
"upper-receiver",
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
]);
export const LOWER_OVERLAP_CATEGORIES = new Set<CategoryId>([
"lower-receiver",
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
]);
export type BuildState = Partial<Record<CategoryId, string>>;
function selectedCount(build: BuildState, set: Set<CategoryId>) {
let count = 0;
for (const cid of set) if (build[cid]) count++;
return count;
}
function selectedList(build: BuildState, set: Set<CategoryId>) {
const list: CategoryId[] = [];
for (const cid of set) if (build[cid]) list.push(cid);
return list;
}
/**
* Chips to show on a given category row (education / “you may not need both”)
*/
export function getOverlapChipsForCategory(
categoryId: CategoryId,
build: BuildState
): OverlapChipModel[] {
const chips: OverlapChipModel[] = [];
const hasCompleteUpper = !!build[COMPLETE_UPPER_CATEGORY];
const hasCompleteLower = !!build[COMPLETE_LOWER_CATEGORY];
// ---- Upper overlap ----
if (categoryId === COMPLETE_UPPER_CATEGORY) {
const count = selectedCount(build, UPPER_OVERLAP_CATEGORIES);
if (count > 0) {
chips.push({
key: "upper-complete-overlaps-subparts",
tone: "warning",
label: "Overlaps selected upper parts",
detail: `${count} upper part${count === 1 ? "" : "s"} also selected`,
});
}
}
if (UPPER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteUpper) {
chips.push({
key: "upper-subpart-in-complete",
tone: "info",
label: "Included in Complete Upper",
detail: "You have both selected (compare if you want)",
});
}
// ---- Lower overlap ----
if (categoryId === COMPLETE_LOWER_CATEGORY) {
const count = selectedCount(build, LOWER_OVERLAP_CATEGORIES);
if (count > 0) {
chips.push({
key: "lower-complete-overlaps-subparts",
tone: "warning",
label: "Overlaps selected lower parts",
detail: `${count} lower part${count === 1 ? "" : "s"} also selected`,
});
}
}
if (LOWER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteLower) {
chips.push({
key: "lower-subpart-in-complete",
tone: "info",
label: "Included in Complete Lower",
detail: "You have both selected (compare if you want)",
});
}
return chips;
}
/**
* Non-blocking “heads up” messages when selecting a part.
* (Use for toasts / shareStatus strip — no auto-removal)
*/
export function getSelectionHints(
nextCategoryId: CategoryId,
build: BuildState
): string[] {
const hints: string[] = [];
if (nextCategoryId === COMPLETE_UPPER_CATEGORY) {
const overlaps = selectedList(build, UPPER_OVERLAP_CATEGORIES);
if (overlaps.length > 0) {
hints.push(
"Heads up: Complete Upper overlaps with some selected upper parts. Nothing was removed — compare options and keep what you want."
);
}
}
if (UPPER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_UPPER_CATEGORY]) {
hints.push(
"Heads up: You also have a Complete Upper selected. Nothing was removed — compare options and keep what you want."
);
}
if (nextCategoryId === COMPLETE_LOWER_CATEGORY) {
const overlaps = selectedList(build, LOWER_OVERLAP_CATEGORIES);
if (overlaps.length > 0) {
hints.push(
"Heads up: Complete Lower overlaps with some selected lower parts. Nothing was removed — compare options and keep what you want."
);
}
}
if (LOWER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_LOWER_CATEGORY]) {
hints.push(
"Heads up: You also have a Complete Lower selected. Nothing was removed — compare options and keep what you want."
);
}
return hints;
}
+90
View File
@@ -0,0 +1,90 @@
// app/lib/catalog.ts
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export type ProductListItem = {
id: string;
name: string;
brand: string;
platform: string;
partRole: string;
categoryKey?: string | null;
price?: number | null;
buyUrl?: string | null;
imageUrl?: string | null;
slug?: string | null; // if your API returns it; otherwise we derive it client-side
};
export type ProductDetail = {
id: string;
name: string;
brand: string;
platform: string;
partRole: string;
rawCategoryKey?: string | null;
description?: string | null;
shortDescription?: string | null;
imageUrl?: string | null;
offers?: Array<{
merchantName?: string;
price?: number | null;
buyUrl?: string | null;
inStock?: boolean | null;
}>;
};
export function slugify(input: string) {
return (input ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
/**
* Your existing list endpoint:
* GET /api/v1/products?platform=AR-15&partRoles=complete-upper
*/
export async function fetchProducts(params: {
platform: string;
partRole: string;
}) {
const { platform, partRole } = params;
const url =
`${API_BASE_URL}/api/v1/products?` +
new URLSearchParams({
platform,
partRoles: partRole,
});
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
const data = (await res.json()) as ProductListItem[];
// Ensure each item has a "productSlug" of the form "{id}-{slugified-name}"
return data.map((p) => ({
...p,
productSlug: `${p.id}-${slugify(p.name)}`,
}));
}
/**
* You likely have (or should add) a detail endpoint like:
* GET /api/v1/products/{id}?platform=AR-15 OR GET /api/v1/products/{id}
*
* We'll call /api/v1/products/{id} and optionally include platform as a query param.
*/
export async function fetchProductById(params: {
id: string;
platform?: string;
}) {
const { id, platform } = params;
const url =
`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}` +
(platform ? `?${new URLSearchParams({ platform })}` : "");
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
return (await res.json()) as ProductDetail;
}
+100
View File
@@ -0,0 +1,100 @@
import type { CategoryId } from "@/types/gunbuilder";
/**
* Normalize backend part roles into a canonical kebab-case form.
* - trims whitespace
* - lowercases
* - converts snake_case / ENUM_CASE to kebab-case
*/
export const normalizePartRole = (role: string) =>
role.trim().toLowerCase().replace(/_/g, "-");
/**
* Maps canonical kebab-case CategoryIds to their associated part roles from the backend.
*
* Notes:
* - Keep the list here as the *source of truth*.
* - The derived `PART_ROLE_TO_CATEGORY` map below stores both the original and normalized keys.
*/
export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
// ===== UPPER =====
"upper-receiver": ["upper-receiver"],
// (optional) Back-compat if anything still uses the old ids:
// upper: ["upper-receiver", "upper"],
"complete-upper": ["complete-upper"],
barrel: ["barrel"],
"gas-block": ["gas-block"],
"gas-tube": ["gas-tube"],
"muzzle-device": ["muzzle-device", "compensator", "brake"],
suppressor: ["suppressor"],
handguard: ["handguard"],
"charging-handle": ["charging-handle"],
bcg: ["bcg", "bolt-carrier-group"],
// ===== LOWER =====
"lower-receiver": ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
// (optional) Back-compat if anything still uses the old ids:
"complete-lower": ["complete-lower"],
"lower-parts": ["lower-parts-kit", "lower-parts"],
trigger: ["trigger", "trigger-kit"],
grip: ["pistol-grip", "grip"],
safety: ["safety", "safety-selector"],
buffer: ["buffer-kit", "buffer"],
stock: ["stock"],
// ===== OPTICS =====
sights: ["sight", "sights", "iron-sights"],
optic: ["optic", "optics"],
// ===== ACCESSORIES =====
magazine: ["magazine", "mag", "magazine-ar15", "magazine-308", "drum-magazine"],
"weapon-light": [
"weapon-light",
"light",
"weapon-light-laser",
"light-laser-combo",
"laser",
],
foregrip: ["vertical-grip", "angled-foregrip", "foregrip", "handstop"],
bipod: ["bipod"],
sling: ["sling", "sling-mount", "sling-swivel", "qd-sling-mount"],
"rail-accessory": [
"rail-section",
"picatinny-rail-section",
"m-lok-rail-section",
"keymod-rail-section",
"rail-cover",
"rail-panel",
],
tools: [
"tool",
"armorer-tool",
"armorer-wrench",
"cleaning-kit",
"bore-snake",
"vise-block",
"torque-wrench",
],
};
/**
* Reverse lookup: backend partRole -> CategoryId.
*
* Implementation details:
* - Stores BOTH the original role and its normalized version as keys.
* - De-dupes role lists per category.
*/
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries(
CATEGORY_TO_PART_ROLES
).reduce((acc, [categoryId, roles]) => {
const unique = Array.from(new Set(roles ?? []));
for (const role of unique) {
acc[role] = categoryId as CategoryId;
acc[normalizePartRole(role)] = categoryId as CategoryId;
}
return acc;
}, {} as Record<string, CategoryId>);
+6
View File
@@ -0,0 +1,6 @@
// lib/cn.ts
import clsx, { type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
+5
View File
@@ -0,0 +1,5 @@
export function formatDate(value: unknown) {
if (!value) return "—";
const d = new Date(value as any);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
}
+72
View File
@@ -0,0 +1,72 @@
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const LAUNCH_ONLY_ROOT = process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT === "true";
// ✅ Forever allow list (always reachable even during launch-only mode)
const ALWAYS_ALLOW = new Set<string>([
"/",
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
]);
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) {
const { pathname } = req.nextUrl;
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") ||
pathname === "/robots.txt" ||
pathname === "/sitemap.xml" ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next();
}
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
return NextResponse.rewrite(new URL("/404", req.url));
}
` 1`
export const config = {
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)).*)",
],
};
+281 -27
View File
@@ -11,8 +11,11 @@
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"next": "14.2.3",
"quill": "^2.0.3",
"quill-image-resize-module": "^3.0.0",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"react-quill": "^2.0.0"
},
"devDependencies": {
"@types/node": "20.12.7",
@@ -557,6 +560,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/quill": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
"license": "MIT",
"dependencies": {
"parchment": "^1.1.2"
}
},
"node_modules/@types/quill/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
@@ -1463,7 +1481,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
@@ -1482,7 +1499,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -1496,7 +1512,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -1610,6 +1625,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1770,6 +1794,26 @@
}
}
},
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1781,7 +1825,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -1799,7 +1842,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
@@ -1857,7 +1899,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -1962,7 +2003,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -1972,7 +2012,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2010,7 +2049,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -2264,7 +2302,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -2537,6 +2574,18 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2544,6 +2593,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2736,7 +2791,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -2767,7 +2821,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -2787,7 +2840,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -2812,7 +2864,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -2973,7 +3024,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3022,7 +3072,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@@ -3051,7 +3100,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3064,7 +3112,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -3080,7 +3127,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -3160,6 +3206,22 @@
"node": ">= 0.4"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -3305,7 +3367,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -3454,7 +3515,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -3802,6 +3862,31 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -3841,7 +3926,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4102,11 +4186,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4280,6 +4379,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4579,6 +4684,97 @@
],
"license": "MIT"
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/quill-image-resize-module": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quill-image-resize-module/-/quill-image-resize-module-3.0.0.tgz",
"integrity": "sha512-1TZBnUxU/WIx5dPyVjQ9yN7C6mLZSp04HyWBEMqT320DIq4MW4JgzlOPDZX5ZpBM3bU6sacU4kTLUc8VgYQZYw==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.4",
"quill": "^1.2.2",
"raw-loader": "^0.5.1"
}
},
"node_modules/quill-image-resize-module/node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/quill-image-resize-module/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/quill-image-resize-module/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/quill-image-resize-module/node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-image-resize-module/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/raw-loader": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz",
"integrity": "sha512-sf7oGoLuaYAScB4VGr0tzetsYlS8EJH6qnTCfQ/WVEa89hALQ4RQfCKt5xCyPQKPDUbVUAIP1QsxAwfAjlDp7Q=="
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4611,6 +4807,67 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-quill": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
"license": "MIT",
"dependencies": {
"@types/quill": "^1.3.10",
"lodash": "^4.17.4",
"quill": "^1.3.7"
},
"peerDependencies": {
"react": "^16 || ^17 || ^18",
"react-dom": "^16 || ^17 || ^18"
}
},
"node_modules/react-quill/node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/react-quill/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/react-quill/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/react-quill/node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/react-quill/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4661,7 +4918,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
@@ -4874,7 +5130,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
@@ -4892,7 +5147,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
+4 -1
View File
@@ -12,8 +12,11 @@
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"next": "14.2.3",
"quill": "^2.0.3",
"quill-image-resize-module": "^3.0.0",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"react-quill": "^2.0.0"
},
"devDependencies": {
"@types/node": "20.12.7",
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+28
View File
@@ -0,0 +1,28 @@
<?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 995.72 154.88">
<defs>
<style>
.cls-1 {
fill: #f0efef;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M249.46,121.28h-41.14V18.23h40.87c5.93,0,9.17,3.37,9.17,9.44v26.57c0,4.86-1.35,7.15-4.99,9.31l-6.34,3.91,6.74,4.18c3.64,2.16,5.12,4.72,5.12,9.58v30.62c0,6.07-3.37,9.44-9.44,9.44ZM242.99,31.18h-19.29v30.21h11.87l7.42-5.26v-24.95ZM243.39,78.93l-7.55-4.99h-12.14v34.39h19.69v-29.4Z"/>
<path class="cls-1" d="M314.34,121.28l-3.1-19.56h-19.29l-3.1,19.56h-15.24v-.27l19.96-102.91h16.18l19.83,102.91v.27h-15.24ZM301.53,41.97l-7.55,46.53h15.11l-7.55-46.53Z"/>
<path class="cls-1" d="M366.94,31.86v89.42h-15.38V31.86h-16.86v-13.62h49.23v13.62h-16.99Z"/>
<path class="cls-1" d="M427.9,31.86v89.42h-15.38V31.86h-16.86v-13.62h49.23v13.62h-16.99Z"/>
<path class="cls-1" d="M459.46,121.28V18.23h15.38v89.29h25.09v13.76h-40.46Z"/>
<path class="cls-1" d="M556.98,121.28h-41.14V18.23h40.87c5.93,0,9.17,3.37,9.17,9.44v26.57c0,4.86-1.35,7.15-4.99,9.31l-6.34,3.91,6.74,4.18c3.64,2.16,5.12,4.72,5.12,9.58v30.62c0,6.07-3.37,9.44-9.44,9.44ZM550.5,31.18h-19.29v30.21h11.87l7.42-5.26v-24.95ZM550.91,78.93l-7.55-4.99h-12.14v34.39h19.69v-29.4Z"/>
<path class="cls-1" d="M627.38,121.28h-32.77c-6.07,0-9.31-3.37-9.31-9.44V18.23h15.38v89.83h20.77V18.23h15.38v93.6c0,6.07-3.37,9.44-9.44,9.44Z"/>
<path class="cls-1" d="M656.25,121.28V18.23h15.38v103.04h-15.38Z"/>
<path class="cls-1" d="M691.04,121.28V18.23h15.38v89.29h25.09v13.76h-40.46Z"/>
<path class="cls-1" d="M778.85,121.28h-31.43V18.23h31.43c12.54,0,19.96,7.69,19.96,19.56v64.06c0,11.87-7.55,19.42-19.96,19.42ZM783.3,40.08c0-5.53-3.51-8.63-9.04-8.63h-11.46v76.61h11.06c6.2,0,9.44-3.37,9.44-8.77v-59.21Z"/>
<path class="cls-1" d="M817.42,121.28V18.23h42.35v13.62h-26.97v30.08h23.2v13.35h-23.2v32.37h27.38v13.62h-42.75Z"/>
<path class="cls-1" d="M914.12,121.28l-16.99-47.88v-9.31h15.11V31.45h-19.42v89.83h-15.38V18.23h40.87c6.07,0,9.31,3.37,9.31,9.44v37.5c0,6.07-3.24,9.31-9.17,9.44h-5.26l16.99,46.13v.54h-16.05Z"/>
<path class="cls-1" d="M986.42,121.28h-31.02c-5.93,0-9.31-3.37-9.31-9.44v-24.55h15.38v21.04h18.88v-20.23l-30.89-29.27c-2.56-2.29-3.37-4.32-3.37-8.23v-22.93c0-6.07,3.37-9.44,9.31-9.44h31.02c5.93,0,9.31,3.37,9.31,9.31v23.06h-15.38v-19.42h-18.88v18.61l30.89,29.13c2.56,2.43,3.37,4.45,3.37,8.36v24.55c0,6.07-3.37,9.44-9.31,9.44Z"/>
</g>
<path class="cls-1" d="M155.87,0v108.05l-46.85,46.84H0V46.84L46.85,0h109.02ZM128.96,14.84H62.16v31.53h35.72l31.08-31.53ZM46.39,46.37v-24.11l-24.12,24.11h24.12ZM140.1,92.75V25.97l-31.55,31.07v35.71h31.55ZM46.39,61.21H15.77v66.78l30.62-30.14v-36.63ZM93.71,61.21h-31.55v31.53h31.55v-31.53ZM93.71,108.51h-36.65c-8.09,9.81-21.24,18.92-28.77,28.74-.72.93-1.79,1.17-1.39,2.79h65.41l1.39-1.39v-30.14ZM133.6,108.51h-25.05v24.11c1.62.4,1.85-.67,2.79-1.39,7.57-5.8,15.12-16.08,22.26-22.73Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Some files were not shown because too many files have changed in this diff Show More