Compare commits
53 Commits
6863b3618c
...
clean-data
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d7809ccf1 | |||
| e202d811ed | |||
| c8bf032f7a | |||
| 5401f6464d | |||
| 57034eefc3 | |||
| bb8ddb6823 | |||
| 70fac2857e | |||
| c01aabb003 | |||
| 061d737c6c | |||
| 34e915f904 | |||
| b1a8dae8ed | |||
| 13edcdce69 | |||
| 856b99b933 | |||
| 008936ff98 | |||
| 47d5cf239d | |||
| 8d9013d0dd | |||
| c06696e66d | |||
| 54ea3243fd | |||
| 61935982b3 | |||
| b224e2d0e0 | |||
| f81974cc0b | |||
| b76ced45f1 | |||
| c6d1bce771 | |||
| 7f3818f795 | |||
| 463fd06a12 | |||
| 4c2d767d09 | |||
| 10c0bd953b | |||
| 607939c468 | |||
| 0f10ff4e09 | |||
| 978b9df96c | |||
| 3ddb48fe58 | |||
| e200667611 | |||
| 4e74a38287 | |||
| 7f59e8fb27 | |||
| e16271663e | |||
| e2d0fb8003 | |||
| 1757aba3e7 | |||
| 777618f684 | |||
| b26dcb947e | |||
| 54c30b1d8a | |||
| 1c4fbed34a | |||
| 8b6d9a015e | |||
| 7c2206ffc3 | |||
| 6b0e0334b0 | |||
| 2cd871b529 | |||
| ce05593127 | |||
| 9e5b86967b | |||
| 6cf3ea7c94 | |||
| caf4817d12 | |||
| 05b30d8682 | |||
| be03424e26 | |||
| fa710d35a1 | |||
| dbcd06d192 |
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
+10
@@ -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
|
||||
@@ -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
|
||||
Generated
+17
@@ -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>
|
||||
Generated
+6
@@ -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>
|
||||
Generated
Generated
+2
@@ -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
@@ -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>
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
You’re 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 || "Couldn’t 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 don’t 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 || "Couldn’t 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 you’re 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 don’t 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 user’s 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 don’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, don’t 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>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -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">
|
||||
We’ll 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 don’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Field, Input } from "@/components/ui/form";
|
||||
|
||||
export default function AdminBetaInvitesPage() {
|
||||
const [dryRun, setDryRun] = useState(true);
|
||||
const [limit, setLimit] = useState("1");
|
||||
const [tokenMinutes, setTokenMinutes] = useState("30");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function runInvites() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
dryRun: String(dryRun),
|
||||
limit: String(limit || "0"),
|
||||
tokenMinutes: String(tokenMinutes || "30"),
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
|
||||
|
||||
setResult(data);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Runs the backend batch invite sender (uses your email templates + tracking).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dryRun}
|
||||
onChange={(e) => setDryRun(e.target.checked)}
|
||||
/>
|
||||
Dry run (do everything except actually send)
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Field label="Limit" htmlFor="limit">
|
||||
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||
<Input
|
||||
id="tokenMinutes"
|
||||
value={tokenMinutes}
|
||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Button onClick={runInvites} disabled={loading}>
|
||||
{loading ? "Running…" : "Send Beta Invites"}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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's next request. All new
|
||||
accounts are created as <span className="text-zinc-300">USER</span> by
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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”
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("You’re 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>
|
||||
);
|
||||
}
|
||||
@@ -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 couldn’t 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
link—or a quick way to choose a part if you haven'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 — we’ll 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. I’ll 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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; we’ll 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
@@ -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, data‑driven 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
@@ -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, you’ll get a sign-in link shortly ✨"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Soft-fail
|
||||
setMagicMessage(
|
||||
"If that email is eligible, you’ll 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 isn’t 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
@@ -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("✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s 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'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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
@@ -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;
|
||||
|
||||
@@ -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,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,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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 rifle’s 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 },
|
||||
];
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>);
|
||||
@@ -0,0 +1,6 @@
|
||||
// lib/cn.ts
|
||||
import clsx, { type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)).*)",
|
||||
],
|
||||
|
||||
};
|
||||
Generated
+281
-27
@@ -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
@@ -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 |
@@ -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
Reference in New Issue
Block a user