Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d7809ccf1 | |||
| e202d811ed | |||
| c8bf032f7a | |||
| 5401f6464d | |||
| 57034eefc3 | |||
| bb8ddb6823 | |||
| 70fac2857e | |||
| c01aabb003 | |||
| 061d737c6c | |||
| 34e915f904 | |||
| b1a8dae8ed | |||
| 13edcdce69 | |||
| 856b99b933 | |||
| 008936ff98 | |||
| 47d5cf239d | |||
| 8d9013d0dd | |||
| c06696e66d | |||
| 54ea3243fd | |||
| 61935982b3 |
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,58 @@
|
|||||||
// app/(account)/account/page.tsx
|
// app/(account)/account/page.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading, token, setSession, logout } = useAuth();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const welcome = searchParams.get("welcome") === "1";
|
||||||
|
|
||||||
|
const needsDisplayName = useMemo(() => {
|
||||||
|
return !user?.displayName || user.displayName.trim().length === 0;
|
||||||
|
}, [user?.displayName]);
|
||||||
|
|
||||||
|
// Only show welcome panel if they actually need setup
|
||||||
|
const showWelcomeCard = welcome && needsDisplayName;
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Display Name state (shared)
|
||||||
|
// ----------------------------
|
||||||
|
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Inline profile editor
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Password state
|
||||||
|
// ----------------------------
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [pw1, setPw1] = useState("");
|
||||||
|
const [pw2, setPw2] = useState("");
|
||||||
|
const [pwSaving, setPwSaving] = useState(false);
|
||||||
|
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canSaveName = useMemo(() => {
|
||||||
|
return displayName.trim().length > 0 && !saving;
|
||||||
|
}, [displayName, saving]);
|
||||||
|
|
||||||
|
const pwMismatch = useMemo(
|
||||||
|
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
|
||||||
|
[pw1, pw2]
|
||||||
|
);
|
||||||
|
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
|
||||||
|
const canSavePassword = useMemo(() => {
|
||||||
|
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
|
||||||
|
}, [pwSaving, pw1, pw2]);
|
||||||
|
|
||||||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
||||||
|
|
||||||
@@ -15,29 +63,376 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const authedUser = user; // user is non-null from here down
|
||||||
|
|
||||||
|
async function saveDisplayName() {
|
||||||
|
setSaving(true);
|
||||||
|
setMsg(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextName = displayName.trim();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ displayName: nextName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(text || "Failed to update profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
const nextUser = {
|
||||||
|
email: authedUser.email,
|
||||||
|
displayName: updated?.displayName ?? nextName,
|
||||||
|
role: authedUser.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) setSession(token, nextUser);
|
||||||
|
|
||||||
|
setMsg("Saved ✅");
|
||||||
|
setEditingName(false);
|
||||||
|
|
||||||
|
// If we came from the welcome flow, clear the param
|
||||||
|
if (welcome) router.replace("/account");
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(e?.message || "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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
|
{/* Welcome / Setup card (only if needed) */}
|
||||||
|
{showWelcomeCard && (
|
||||||
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Profile</h2>
|
<h2 className="text-base font-semibold">Welcome to the beta 👋</h2>
|
||||||
<p className="text-sm opacity-70">
|
<p className="text-sm text-zinc-300/80">
|
||||||
Basic account info (we’ll expand this soon).
|
Quick setup and you’re ready to cook.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-2">
|
{/* Display name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-zinc-200">
|
||||||
|
Display name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
|
||||||
|
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={saveDisplayName}
|
||||||
|
disabled={!canSaveName}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
{msg && (
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
msg.toLowerCase().includes("couldn")
|
||||||
|
? "text-red-300"
|
||||||
|
: "text-zinc-200/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="pt-2 border-t border-white/10">
|
||||||
|
<p className="text-xs text-zinc-300/80">
|
||||||
|
Want password login later? Optional (but recommended).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!showPassword ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPassword(true)}
|
||||||
|
className="mt-2 text-xs underline opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Set a password
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pw1}
|
||||||
|
onChange={(e) => setPw1(e.target.value)}
|
||||||
|
placeholder="New password (min 8 chars)"
|
||||||
|
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pw2}
|
||||||
|
onChange={(e) => setPw2(e.target.value)}
|
||||||
|
placeholder="Confirm password"
|
||||||
|
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(pwTooShort || pwMismatch) && (
|
||||||
|
<div className="text-xs text-red-300">
|
||||||
|
{pwTooShort
|
||||||
|
? "Password must be at least 8 characters."
|
||||||
|
: "Passwords 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">
|
<div className="text-sm">
|
||||||
<span className="opacity-70">Email:</span>{" "}
|
<span className="opacity-70">Email:</span>{" "}
|
||||||
<span className="font-medium">{user.email}</span>
|
<span className="font-medium">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Display name (inline editable) */}
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="opacity-70">Display name:</span>{" "}
|
<span className="opacity-70">Display name:</span>{" "}
|
||||||
|
{!editingName ? (
|
||||||
|
<>
|
||||||
<span className="font-medium">{user.displayName || "—"}</span>
|
<span className="font-medium">{user.displayName || "—"}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingName(true);
|
||||||
|
setMsg(null);
|
||||||
|
setDisplayName(user.displayName ?? "");
|
||||||
|
}}
|
||||||
|
className="ml-3 text-xs underline opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Your display name"
|
||||||
|
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={saveDisplayName}
|
||||||
|
disabled={!canSaveName}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingName(false);
|
||||||
|
setDisplayName(user.displayName ?? "");
|
||||||
|
setMsg(null);
|
||||||
|
}}
|
||||||
|
className="text-xs opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{msg && (
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
msg.toLowerCase().includes("couldn")
|
||||||
|
? "text-red-300"
|
||||||
|
: "text-zinc-200/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="opacity-70">Role:</span>{" "}
|
<span className="opacity-70">Role:</span>{" "}
|
||||||
<span className="font-medium">{user.role}</span>
|
<span className="font-medium">{user.role}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Optional: quick link to set password later */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowPassword(true);
|
||||||
|
// Scroll 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+22
-21
@@ -1,56 +1,57 @@
|
|||||||
// app/(account)/layout.tsx
|
// app/(account)/layout.tsx
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import AccountChrome from "./AccountChrome";
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: "/account", label: "My Account" },
|
{ href: "/account", label: "My Account" },
|
||||||
{ href: "/account/settings", label: "Settings" },
|
{ href: "/account/settings", label: "Settings" },
|
||||||
|
{ href: "/vault", label: "My Vault" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AccountLayout({
|
export default function AccountLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<AccountChrome />
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Account</h1>
|
<div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
|
||||||
<p className="text-sm opacity-70">
|
Account
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold text-white/90">
|
||||||
|
Settings & Profile
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-white/60">
|
||||||
Profile, password, and account settings.
|
Profile, password, and account settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/builder" className="text-sm opacity-80 hover:underline">
|
<Link
|
||||||
|
href="/builder"
|
||||||
|
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
|
||||||
|
>
|
||||||
← Back to Builder
|
← Back to Builder
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-[240px_1fr]">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<aside className="rounded-xl border border-white/10 bg-white/5 p-3">
|
<aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{nav.map((item) => (
|
{nav.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="rounded-lg px-3 py-2 text-sm opacity-80 hover:bg-white/10 hover:opacity-100"
|
className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="my-2 border-t border-white/10" />
|
|
||||||
<Link
|
|
||||||
href="/vault"
|
|
||||||
className="rounded-lg px-3 py-2 text-sm opacity-80 hover:bg-white/10 hover:opacity-100"
|
|
||||||
>
|
|
||||||
My Vault
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="rounded-xl border border-white/10 bg-white/5 p-4">
|
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
// app/(builder)/builder/page.tsx
|
// app/(builder)/builder/page.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Battl Builder - Main builder page
|
||||||
|
*
|
||||||
|
* Key flows:
|
||||||
|
* 1) Build selections are stored locally (localStorage) so users can come back.
|
||||||
|
* 2) "Save As…" creates a NEW saved build in the user's Vault (server-side).
|
||||||
|
* 3) Vault -> Builder loads builds via ?load=<uuid> (also supports legacy ?build=<uuid>).
|
||||||
|
*
|
||||||
|
* Notes for devs:
|
||||||
|
* - We intentionally keep ONE save-to-vault handler: handleSaveAs()
|
||||||
|
* - Toast is used for clear user feedback (save success/fail)
|
||||||
|
*
|
||||||
|
* Auth note:
|
||||||
|
* - /api/v1/builds/me/* requires JWT.
|
||||||
|
* - The builder can render before AuthContext hydrates from localStorage, so we
|
||||||
|
* guard “load build” calls until auth is ready to avoid a 401.
|
||||||
|
*/
|
||||||
|
|
||||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { X, Link2, Square, CheckSquare } from "lucide-react";
|
||||||
|
|
||||||
|
import { useApi } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
||||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
|
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
|
||||||
import { X, Link2, Square, CheckSquare } from "lucide-react";
|
|
||||||
|
|
||||||
// ✅ Centralized overlap rules + helpers
|
// ✅ Centralized overlap rules + helpers
|
||||||
import OverlapChip from "@/components/builder/OverlapChip";
|
import OverlapChip from "@/components/builder/OverlapChip";
|
||||||
@@ -31,6 +53,11 @@ type GunbuilderProductFromApi = {
|
|||||||
|
|
||||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
const isUuid = (v: string) =>
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||||
|
v
|
||||||
|
);
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
@@ -114,10 +141,29 @@ export default function GunbuilderPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
// ✅ Auth: we need the token ready before calling /builds/me/*
|
||||||
|
const { token, loading: authLoading, getAuthHeaders } = useAuth();
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Save As… modal state (Vault variants)
|
||||||
|
// -----------------------------
|
||||||
|
const [saveAsOpen, setSaveAsOpen] = useState(false);
|
||||||
|
const [saveAsTitle, setSaveAsTitle] = useState("");
|
||||||
|
const [saveAsDesc, setSaveAsDesc] = useState("");
|
||||||
|
const [saveAsSaving, setSaveAsSaving] = useState(false); // prevents double-submit
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Parts data state
|
||||||
|
// -----------------------------
|
||||||
const [parts, setParts] = useState<Part[]>([]);
|
const [parts, setParts] = useState<Part[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Platform state (AR-15/AR-10/AR-9)
|
||||||
|
// -----------------------------
|
||||||
const isValidPlatform = (
|
const isValidPlatform = (
|
||||||
value: string | null
|
value: string | null
|
||||||
): value is (typeof PLATFORMS)[number] =>
|
): value is (typeof PLATFORMS)[number] =>
|
||||||
@@ -129,6 +175,9 @@ export default function GunbuilderPage() {
|
|||||||
return isValidPlatform(initial) ? initial : "AR-15";
|
return isValidPlatform(initial) ? initial : "AR-15";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Build state (categoryId -> productId), persisted locally
|
||||||
|
// -----------------------------
|
||||||
const [build, setBuild] = useState<BuildState>(() => {
|
const [build, setBuild] = useState<BuildState>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -143,6 +192,20 @@ export default function GunbuilderPage() {
|
|||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Toast notifications (save success / errors)
|
||||||
|
// -----------------------------
|
||||||
|
type Toast = { type: "success" | "error" | "info"; message: string };
|
||||||
|
const [toast, setToast] = useState<Toast | null>(null);
|
||||||
|
|
||||||
|
const showToast = useCallback((t: Toast) => {
|
||||||
|
setToast(t);
|
||||||
|
window.setTimeout(() => setToast(null), 3500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Misc UI state
|
||||||
|
// -----------------------------
|
||||||
const [shareStatus, setShareStatus] = useState<string | null>(null);
|
const [shareStatus, setShareStatus] = useState<string | null>(null);
|
||||||
const [shareUrl, setShareUrl] = useState<string>("");
|
const [shareUrl, setShareUrl] = useState<string>("");
|
||||||
|
|
||||||
@@ -153,7 +216,9 @@ export default function GunbuilderPage() {
|
|||||||
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
|
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
|
||||||
const processedActionKeyRef = useRef<string>("");
|
const processedActionKeyRef = useRef<string>("");
|
||||||
|
|
||||||
// ---------- Derived ----------
|
// -----------------------------
|
||||||
|
// Derived collections
|
||||||
|
// -----------------------------
|
||||||
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
||||||
const grouped = {} as Record<CategoryId, Part[]>;
|
const grouped = {} as Record<CategoryId, Part[]>;
|
||||||
for (const category of CATEGORIES) {
|
for (const category of CATEGORIES) {
|
||||||
@@ -177,8 +242,26 @@ export default function GunbuilderPage() {
|
|||||||
});
|
});
|
||||||
}, [build, parts]);
|
}, [build, parts]);
|
||||||
|
|
||||||
|
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(build).map(([categoryId, partId]) => [
|
||||||
|
categoryId as CategoryId,
|
||||||
|
partId ? true : false,
|
||||||
|
])
|
||||||
|
) as Record<CategoryId, unknown>,
|
||||||
|
[build]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPrice = useMemo(
|
||||||
|
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
||||||
|
[selectedParts]
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
// Role -> Category resolver
|
// Role -> Category resolver
|
||||||
// NOTE: defensive while backend roles/mappings evolve
|
// NOTE: defensive while backend roles/mappings evolve
|
||||||
|
// -----------------------------
|
||||||
const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
|
const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
|
||||||
if (normalizedRole === "upper-receiver") return "upper-receiver";
|
if (normalizedRole === "upper-receiver") return "upper-receiver";
|
||||||
if (normalizedRole.includes("complete-upper")) return "complete-upper";
|
if (normalizedRole.includes("complete-upper")) return "complete-upper";
|
||||||
@@ -271,22 +354,9 @@ export default function GunbuilderPage() {
|
|||||||
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
|
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
|
// -----------------------------
|
||||||
() =>
|
// Selection handler (with hint warnings)
|
||||||
Object.fromEntries(
|
// -----------------------------
|
||||||
Object.entries(build).map(([categoryId, partId]) => [
|
|
||||||
categoryId as CategoryId,
|
|
||||||
partId ? true : false,
|
|
||||||
])
|
|
||||||
) as Record<CategoryId, unknown>,
|
|
||||||
[build]
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalPrice = useMemo(
|
|
||||||
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
|
||||||
[selectedParts]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectPart = useCallback(
|
const handleSelectPart = useCallback(
|
||||||
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
|
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
|
||||||
const warn = (msg: string) => {
|
const warn = (msg: string) => {
|
||||||
@@ -305,7 +375,9 @@ export default function GunbuilderPage() {
|
|||||||
[build]
|
[build]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------- Fetch products ----------
|
// -----------------------------
|
||||||
|
// Fetch products (scoped + universal merge)
|
||||||
|
// -----------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -317,7 +389,6 @@ export default function GunbuilderPage() {
|
|||||||
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
|
const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
|
||||||
platform
|
platform
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
const universalUrl = `${API_BASE_URL}/api/v1/products`;
|
const universalUrl = `${API_BASE_URL}/api/v1/products`;
|
||||||
|
|
||||||
const [scopedRes, universalRes] = await Promise.all([
|
const [scopedRes, universalRes] = await Promise.all([
|
||||||
@@ -350,6 +421,7 @@ export default function GunbuilderPage() {
|
|||||||
const categoryId = resolveCategoryId(normalizedRole);
|
const categoryId = resolveCategoryId(normalizedRole);
|
||||||
if (!categoryId) return null;
|
if (!categoryId) return null;
|
||||||
|
|
||||||
|
// Only accept universal fetch results for "universal" categories
|
||||||
if (
|
if (
|
||||||
data === universalData &&
|
data === universalData &&
|
||||||
!UNIVERSAL_CATEGORIES.has(categoryId)
|
!UNIVERSAL_CATEGORIES.has(categoryId)
|
||||||
@@ -377,6 +449,7 @@ export default function GunbuilderPage() {
|
|||||||
const scopedParts = normalize(scopedData);
|
const scopedParts = normalize(scopedData);
|
||||||
const universalParts = normalize(universalData);
|
const universalParts = normalize(universalData);
|
||||||
|
|
||||||
|
// Merge (avoid duplicates across calls)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const merged: Part[] = [];
|
const merged: Part[] = [];
|
||||||
for (const p of [...scopedParts, ...universalParts]) {
|
for (const p of [...scopedParts, ...universalParts]) {
|
||||||
@@ -399,7 +472,9 @@ export default function GunbuilderPage() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
// ✅ Persist build state whenever it changes
|
// -----------------------------
|
||||||
|
// Persist build to localStorage
|
||||||
|
// -----------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
@@ -409,7 +484,9 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
}, [build]);
|
}, [build]);
|
||||||
|
|
||||||
// When the platform changes, clear ONLY platform-scoped selections.
|
// -----------------------------
|
||||||
|
// When platform changes, clear ONLY platform-scoped selections
|
||||||
|
// -----------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = lastPlatformRef.current;
|
const prev = lastPlatformRef.current;
|
||||||
lastPlatformRef.current = platform;
|
lastPlatformRef.current = platform;
|
||||||
@@ -433,21 +510,43 @@ export default function GunbuilderPage() {
|
|||||||
});
|
});
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
// ✅ Load a saved build from Vault via ?load=<uuid>
|
// -----------------------------
|
||||||
|
// Load a saved build from Vault via ?load=<uuid> OR legacy ?build=<uuid>
|
||||||
|
// -----------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUuid = searchParams.get("load");
|
const loadUuid = searchParams.get("load");
|
||||||
if (!loadUuid) return;
|
const buildParam = searchParams.get("build");
|
||||||
|
|
||||||
|
const uuidToLoad =
|
||||||
|
(loadUuid && isUuid(loadUuid) ? loadUuid : null) ||
|
||||||
|
(buildParam && isUuid(buildParam) ? buildParam : null);
|
||||||
|
|
||||||
|
if (!uuidToLoad) return;
|
||||||
|
|
||||||
|
// ✅ Wait for AuthProvider hydration before calling /me endpoints.
|
||||||
|
if (authLoading) return;
|
||||||
|
|
||||||
|
// ✅ If not logged in, don't spam the backend; show a helpful message instead.
|
||||||
|
if (!token) {
|
||||||
|
setShareStatus("Please log in to load builds from your Vault.");
|
||||||
|
window.setTimeout(() => setShareStatus(null), 4500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
try {
|
try {
|
||||||
setShareStatus("Loading build…");
|
setShareStatus("Loading build…");
|
||||||
|
|
||||||
// NOTE: your backend GET route is /api/v1/products/builds/{uuid}
|
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
|
||||||
|
// explicitly attach JWT headers.
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/v1/products/builds/${encodeURIComponent(
|
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
|
||||||
loadUuid
|
{
|
||||||
)}`,
|
headers: {
|
||||||
{ credentials: "include" }
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -466,10 +565,15 @@ export default function GunbuilderPage() {
|
|||||||
setBuild(nextBuild);
|
setBuild(nextBuild);
|
||||||
setShareStatus(`Loaded: ${dto.title}`);
|
setShareStatus(`Loaded: ${dto.title}`);
|
||||||
|
|
||||||
// Clean URL so reload doesn't re-fetch
|
// Clean URL so refresh doesn't re-load repeatedly
|
||||||
const qp = new URLSearchParams(searchParams.toString());
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
qp.delete("load");
|
qp.delete("load");
|
||||||
router.replace(`/builder?${qp.toString()}`, { scroll: false });
|
if (buildParam && isUuid(buildParam)) qp.delete("build");
|
||||||
|
|
||||||
|
const next = qp.toString();
|
||||||
|
router.replace(next ? `/builder?${next}` : "/builder", {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setShareStatus(e?.message ?? "Failed to load build");
|
setShareStatus(e?.message ?? "Failed to load build");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -478,12 +582,90 @@ export default function GunbuilderPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
run();
|
run();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [
|
||||||
}, [searchParams, router]);
|
searchParams,
|
||||||
|
router,
|
||||||
|
authLoading,
|
||||||
|
token,
|
||||||
|
getAuthHeaders,
|
||||||
|
// API_BASE_URL is a module constant; no need to include
|
||||||
|
]);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// SAVE AS… (single source of truth for saving to Vault)
|
||||||
|
// - Creates a NEW saved build server-side
|
||||||
|
// - Shows toast so users know it worked
|
||||||
|
// -----------------------------
|
||||||
|
const handleSaveAs = async () => {
|
||||||
|
if (selectedParts.length === 0) {
|
||||||
|
showToast({ type: "error", message: "Add a few parts before saving." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = (saveAsTitle || `${platform} Build`).trim();
|
||||||
|
if (!title) {
|
||||||
|
showToast({ type: "error", message: "Title is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Object.entries(build)
|
||||||
|
.filter(([, partId]) => !!partId)
|
||||||
|
.map(([categoryId, partId]) => ({
|
||||||
|
productId: Number(partId),
|
||||||
|
slot: categoryId,
|
||||||
|
position: 0,
|
||||||
|
quantity: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaveAsSaving(true);
|
||||||
|
showToast({ type: "info", message: "Saving build…" });
|
||||||
|
|
||||||
|
const res = await api.post("/api/v1/builds/me", {
|
||||||
|
title,
|
||||||
|
description: saveAsDesc?.trim() || null,
|
||||||
|
isPublic: false,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(txt || `Save failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await res.json(); // BuildDto (expects { uuid, ... })
|
||||||
|
|
||||||
|
// UX: copy UUID for quick testing + user confidence
|
||||||
|
try {
|
||||||
|
if (saved?.uuid) await navigator.clipboard.writeText(saved.uuid);
|
||||||
|
} catch {
|
||||||
|
// ignore clipboard failures
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
type: "success",
|
||||||
|
message: "Saved to Vault ✅ (UUID copied)",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal + reset inputs for next variant
|
||||||
|
setSaveAsOpen(false);
|
||||||
|
setSaveAsTitle("");
|
||||||
|
setSaveAsDesc("");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: e?.message ?? "Save failed",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaveAsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
// Handle URL query parameters:
|
// Handle URL query parameters:
|
||||||
// - ?select=categoryId:partId
|
// - ?select=categoryId:partId
|
||||||
// - ?remove=categoryId
|
// - ?remove=categoryId
|
||||||
|
// -----------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectParam = searchParams.get("select");
|
const selectParam = searchParams.get("select");
|
||||||
const removeParam = searchParams.get("remove");
|
const removeParam = searchParams.get("remove");
|
||||||
@@ -522,12 +704,11 @@ export default function GunbuilderPage() {
|
|||||||
|
|
||||||
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||||
|
|
||||||
|
// Keep URL clean/stable
|
||||||
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
|
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const current = `${window.location.pathname}${window.location.search}`;
|
const current = `${window.location.pathname}${window.location.search}`;
|
||||||
if (current !== nextUrl) {
|
if (current !== nextUrl) router.replace(nextUrl, { scroll: false });
|
||||||
router.replace(nextUrl, { scroll: false });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
router.replace(nextUrl, { scroll: false });
|
router.replace(nextUrl, { scroll: false });
|
||||||
}
|
}
|
||||||
@@ -541,7 +722,7 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, platform]);
|
}, [searchParams, platform]);
|
||||||
|
|
||||||
// Build share URL whenever build changes
|
// Build share URL whenever build changes (client-side only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
@@ -571,53 +752,7 @@ export default function GunbuilderPage() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handler to Save user build to account
|
// Copy summary text to clipboard
|
||||||
const handleSaveToAccount = async () => {
|
|
||||||
if (selectedParts.length === 0) {
|
|
||||||
setShareStatus("Add a few parts before saving.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = `${platform} Build`;
|
|
||||||
|
|
||||||
const items = Object.entries(build)
|
|
||||||
.filter(([, partId]) => !!partId)
|
|
||||||
.map(([categoryId, partId]) => ({
|
|
||||||
productId: Number(partId),
|
|
||||||
slot: categoryId,
|
|
||||||
position: 0,
|
|
||||||
quantity: 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
setShareStatus("Saving build…");
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
|
||||||
title,
|
|
||||||
description: null,
|
|
||||||
isPublic: false,
|
|
||||||
items,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text().catch(() => "");
|
|
||||||
throw new Error(`Save failed (${res.status}) ${txt}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await res.json(); // BuildDto
|
|
||||||
setShareStatus(`Saved! Build UUID: ${saved.uuid}`);
|
|
||||||
} catch (e: any) {
|
|
||||||
setShareStatus(e?.message ?? "Save failed");
|
|
||||||
} finally {
|
|
||||||
window.setTimeout(() => setShareStatus(null), 4500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (selectedParts.length === 0) {
|
if (selectedParts.length === 0) {
|
||||||
setShareStatus("Add a few parts before sharing your build.");
|
setShareStatus("Add a few parts before sharing your build.");
|
||||||
@@ -625,7 +760,7 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push("B Build");
|
lines.push("Battl Build");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`Total: $${totalPrice.toFixed(2)}`);
|
lines.push(`Total: $${totalPrice.toFixed(2)}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -685,8 +820,8 @@ export default function GunbuilderPage() {
|
|||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: "Shadow Standard — The Armory Build",
|
title: "BATTL — The Builder",
|
||||||
text: "Check out my Shadow Standard Armory build.",
|
text: "Check out my Battl Build.",
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
});
|
});
|
||||||
setShareStatus("Share dialog opened.");
|
setShareStatus("Share dialog opened.");
|
||||||
@@ -702,6 +837,99 @@ export default function GunbuilderPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
|
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
|
||||||
|
{/* Toast (top-right) */}
|
||||||
|
{toast && (
|
||||||
|
<div className="fixed top-4 right-4 z-50">
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur
|
||||||
|
${
|
||||||
|
toast.type === "success"
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-100"
|
||||||
|
: toast.type === "error"
|
||||||
|
? "border-red-500/30 bg-red-500/15 text-red-100"
|
||||||
|
: "border-white/10 bg-white/10 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save As… Modal */}
|
||||||
|
{saveAsOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="w-full max-w-lg rounded-xl border border-white/10 bg-zinc-950 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold">Save As…</div>
|
||||||
|
<div className="text-xs text-zinc-400">
|
||||||
|
Create a new saved build variant in your Vault.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSaveAsOpen(false)}
|
||||||
|
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-300">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={saveAsTitle}
|
||||||
|
onChange={(e) => setSaveAsTitle(e.target.value)}
|
||||||
|
placeholder={`e.g. "${platform} Mk2 (Suppressed)"`}
|
||||||
|
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-300">
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={saveAsDesc}
|
||||||
|
onChange={(e) => setSaveAsDesc(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Notes for future-you…"
|
||||||
|
className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSaveAsOpen(false)}
|
||||||
|
className="text-sm text-zinc-300 hover:underline"
|
||||||
|
disabled={saveAsSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveAs}
|
||||||
|
disabled={saveAsSaving}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-semibold text-black ${
|
||||||
|
saveAsSaving
|
||||||
|
? "bg-amber-400/60 cursor-not-allowed"
|
||||||
|
: "bg-amber-400 hover:bg-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveAsSaving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
@@ -710,7 +938,7 @@ export default function GunbuilderPage() {
|
|||||||
BATTL BUILDERS
|
BATTL BUILDERS
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
BattlBuilder <span className="text-amber-300">Early Access</span>
|
Builder <span className="text-amber-300">Early Access</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||||
Explore components from trusted brands, choose one part per
|
Explore components from trusted brands, choose one part per
|
||||||
@@ -728,6 +956,7 @@ export default function GunbuilderPage() {
|
|||||||
const next = e.target.value as (typeof PLATFORMS)[number];
|
const next = e.target.value as (typeof PLATFORMS)[number];
|
||||||
setPlatform(next);
|
setPlatform(next);
|
||||||
|
|
||||||
|
// Keep URL in sync, and clear transient action params
|
||||||
const qp = new URLSearchParams(searchParams.toString());
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
qp.set("platform", next);
|
qp.set("platform", next);
|
||||||
qp.delete("select");
|
qp.delete("select");
|
||||||
@@ -760,6 +989,7 @@ export default function GunbuilderPage() {
|
|||||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
||||||
My Build Breakdown
|
My Build Breakdown
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-zinc-400">Current build total</div>
|
<div className="text-xs text-zinc-400">Current build total</div>
|
||||||
<div className="text-2xl font-semibold text-amber-300">
|
<div className="text-2xl font-semibold text-amber-300">
|
||||||
@@ -787,10 +1017,12 @@ export default function GunbuilderPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// Reset local build state
|
||||||
setBuild({});
|
setBuild({});
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
showToast({ type: "info", message: "Build cleared." });
|
||||||
}}
|
}}
|
||||||
disabled={selectedParts.length === 0}
|
disabled={selectedParts.length === 0}
|
||||||
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
|
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
|
||||||
@@ -802,10 +1034,10 @@ export default function GunbuilderPage() {
|
|||||||
Clear Build
|
Clear Build
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Save Build */}
|
{/* Save As… triggers modal */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSaveToAccount}
|
onClick={() => setSaveAsOpen(true)}
|
||||||
disabled={selectedParts.length === 0}
|
disabled={selectedParts.length === 0}
|
||||||
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
|
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
|
||||||
selectedParts.length === 0
|
selectedParts.length === 0
|
||||||
@@ -813,8 +1045,16 @@ export default function GunbuilderPage() {
|
|||||||
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
|
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Save Build
|
Save As…
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Optional: quick access to Vault */}
|
||||||
|
<Link
|
||||||
|
href="/vault"
|
||||||
|
className="w-full sm:w-auto rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm font-medium text-zinc-200 hover:bg-white/10 text-center"
|
||||||
|
>
|
||||||
|
Go to Vault →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -829,6 +1069,7 @@ export default function GunbuilderPage() {
|
|||||||
Share this link to let others view your build or bookmark it
|
Share this link to let others view your build or bookmark it
|
||||||
to come back later.
|
to come back later.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
|
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
<div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
@@ -841,6 +1082,7 @@ export default function GunbuilderPage() {
|
|||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 md:flex-none md:mt-5">
|
<div className="flex gap-2 md:flex-none md:mt-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -849,6 +1091,7 @@ export default function GunbuilderPage() {
|
|||||||
>
|
>
|
||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
@@ -968,12 +1211,14 @@ export default function GunbuilderPage() {
|
|||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{groupCategories.map((category) => {
|
{groupCategories.map((category) => {
|
||||||
const categoryParts = partsByCategory[category.id] ?? [];
|
const categoryParts =
|
||||||
|
partsByCategory[category.id] ?? [];
|
||||||
const selectedPartId = build[category.id];
|
const selectedPartId = build[category.id];
|
||||||
|
|
||||||
const selectedPart = selectedPartId
|
const selectedPart = selectedPartId
|
||||||
? categoryParts.find((p) => p.id === selectedPartId) ??
|
? categoryParts.find(
|
||||||
parts.find((p) => p.id === selectedPartId)
|
(p) => p.id === selectedPartId
|
||||||
|
) ?? parts.find((p) => p.id === selectedPartId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const hasParts = categoryParts.length > 0;
|
const hasParts = categoryParts.length > 0;
|
||||||
@@ -992,10 +1237,27 @@ export default function GunbuilderPage() {
|
|||||||
{selectedPart ? (
|
{selectedPart ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(
|
||||||
|
platform
|
||||||
|
)}/${encodeURIComponent(
|
||||||
|
category.id
|
||||||
|
)}/${encodeURIComponent(
|
||||||
|
`${
|
||||||
|
selectedPart.id
|
||||||
|
}-${selectedPart.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "")}`
|
||||||
|
)}`}
|
||||||
|
className="block text-[0.7rem] text-zinc-500 line-clamp-1 transition-colors hover:text-amber-300 hover:underline underline-offset-2"
|
||||||
|
title="View part details"
|
||||||
|
>
|
||||||
{selectedPart.name}
|
{selectedPart.name}
|
||||||
|
</Link>{" "}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Category overlap chips */}
|
{/* Overlap chips help explain conflicts / dependencies */}
|
||||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
{getOverlapChipsForCategory(
|
{getOverlapChipsForCategory(
|
||||||
category.id as CategoryId,
|
category.id as CategoryId,
|
||||||
@@ -1038,7 +1300,7 @@ export default function GunbuilderPage() {
|
|||||||
—
|
—
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Caliber placeholder */}
|
{/* Caliber placeholder (future: derive from build profile) */}
|
||||||
<td className="px-3 py-2 align-top text-zinc-400">
|
<td className="px-3 py-2 align-top text-zinc-400">
|
||||||
—
|
—
|
||||||
</td>
|
</td>
|
||||||
@@ -1077,10 +1339,9 @@ export default function GunbuilderPage() {
|
|||||||
</>
|
</>
|
||||||
) : hasParts ? (
|
) : hasParts ? (
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={`/parts/p/${encodeURIComponent(
|
||||||
pathname: `/parts/p/${platform}/${category.id}`,
|
platform
|
||||||
query: platform ? { platform } : {},
|
)}/${encodeURIComponent(category.id)}`}
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -204,7 +204,7 @@ export default function ProductDetailsPage() {
|
|||||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
className="hover:text-zinc-300"
|
className="hover:text-zinc-300"
|
||||||
>
|
>
|
||||||
The Armory
|
The Builder
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-zinc-700">/</span>
|
<span className="text-zinc-700">/</span>
|
||||||
<Link
|
<Link
|
||||||
+1
-1
@@ -370,7 +370,7 @@ export default function CategoryPage() {
|
|||||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to The Armory
|
← Back to the Builder
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
+165
-91
@@ -5,19 +5,18 @@ import Link from "next/link";
|
|||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import type { CategoryId } from "@/types/gunbuilder";
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
import {
|
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||||
PART_ROLE_TO_CATEGORY,
|
|
||||||
normalizePartRole,
|
|
||||||
} from "@/lib/catalogMappings";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Shapes
|
* API Shapes
|
||||||
* - OfferFromApi: what /api/v1/products/:id returns in offers[]
|
* - OfferFromApi: what /api/v1/products/:id/offers returns
|
||||||
* - GunbuilderProductFromApi: the product detail contract (v1)
|
* - GunbuilderProductFromApi: product detail contract (v1)
|
||||||
*/
|
*/
|
||||||
type OfferFromApi = {
|
type OfferFromApi = {
|
||||||
|
id?: string | number;
|
||||||
merchantName?: string | null;
|
merchantName?: string | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
|
originalPrice?: number | null;
|
||||||
buyUrl?: string | null;
|
buyUrl?: string | null;
|
||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
lastUpdated?: string | null;
|
lastUpdated?: string | null;
|
||||||
@@ -43,7 +42,7 @@ type GunbuilderProductFromApi = {
|
|||||||
shortDescription?: string | null;
|
shortDescription?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
// New: offers[] from the API
|
// (May be empty depending on endpoint; we now fetch offers separately)
|
||||||
offers?: OfferFromApi[] | null;
|
offers?: OfferFromApi[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,11 +71,44 @@ function formatPrice(price: number | null | undefined): string {
|
|||||||
return `$${price.toFixed(2)}`;
|
return `$${price.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a friendly merchant label.
|
||||||
|
* Priority:
|
||||||
|
* 1) offer.merchantName (best)
|
||||||
|
* 2) hostname from offer.buyUrl (fallback)
|
||||||
|
* 3) "Retailer"
|
||||||
|
*/
|
||||||
|
function getHostFromUrl(url?: string | null): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.host.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTitleCase(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function merchantLabel(offer: OfferFromApi): string {
|
||||||
|
const name = offer.merchantName?.trim();
|
||||||
|
if (name) return name;
|
||||||
|
|
||||||
|
const host = getHostFromUrl(offer.buyUrl);
|
||||||
|
if (host) return toTitleCase(host.split(".")[0]);
|
||||||
|
|
||||||
|
return "Retailer";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort offers:
|
* Sort offers:
|
||||||
* 1) lowest price first
|
* 1) lowest price first
|
||||||
* 2) in-stock before out-of-stock
|
* 2) in-stock before out-of-stock
|
||||||
* 3) merchant name tiebreaker
|
* 3) merchant label tiebreaker
|
||||||
*/
|
*/
|
||||||
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
||||||
return [...offers].sort((a, b) => {
|
return [...offers].sort((a, b) => {
|
||||||
@@ -89,7 +121,7 @@ function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
|||||||
const bStock = b.inStock === false ? 1 : 0;
|
const bStock = b.inStock === false ? 1 : 0;
|
||||||
if (aStock !== bStock) return aStock - bStock;
|
if (aStock !== bStock) return aStock - bStock;
|
||||||
|
|
||||||
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
|
return merchantLabel(a).localeCompare(merchantLabel(b));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +185,7 @@ export default function ProductDetailsPage() {
|
|||||||
return () => window.removeEventListener("storage", onStorage);
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedPartIdForCategory = categoryId
|
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||||
? build?.[categoryId]
|
|
||||||
: undefined;
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
!!categoryId &&
|
!!categoryId &&
|
||||||
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||||
@@ -165,6 +195,13 @@ export default function ProductDetailsPage() {
|
|||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Offers fetch state (from /products/:id/offers)
|
||||||
|
const [offersFromApi, setOffersFromApi] = useState<OfferFromApi[]>([]);
|
||||||
|
const [offersLoading, setOffersLoading] = useState(false);
|
||||||
|
|
||||||
|
// Image modal state
|
||||||
|
const [isImageOpen, setIsImageOpen] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route normalization:
|
* Route normalization:
|
||||||
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
|
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
|
||||||
@@ -181,14 +218,7 @@ export default function ProductDetailsPage() {
|
|||||||
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||||
platformParam,
|
|
||||||
platform,
|
|
||||||
partRoleParam,
|
|
||||||
productSlug,
|
|
||||||
router,
|
|
||||||
searchParams,
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch product details from API:
|
* Fetch product details from API:
|
||||||
@@ -229,6 +259,41 @@ export default function ProductDetailsPage() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [numericId]);
|
}, [numericId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch offers (rich offer data)
|
||||||
|
* GET /api/v1/products/:id/offers
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numericId) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchOffers() {
|
||||||
|
try {
|
||||||
|
setOffersLoading(true);
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load offers (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: OfferFromApi[] = await res.json();
|
||||||
|
setOffersFromApi(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
// offers failing shouldn't kill the page
|
||||||
|
setOffersFromApi([]);
|
||||||
|
} finally {
|
||||||
|
setOffersLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchOffers();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [numericId]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builder selection toggle:
|
* Builder selection toggle:
|
||||||
* - If selected -> remove from build
|
* - If selected -> remove from build
|
||||||
@@ -261,19 +326,16 @@ export default function ProductDetailsPage() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Offers normalization:
|
* Offers normalization:
|
||||||
* - If product.offers exists, use & sort it (real offers).
|
* - Prefer offers endpoint data
|
||||||
* - Otherwise, fallback to a single offer derived from product.buyUrl/product.price (legacy).
|
* - Fallback to legacy single-offer derived from product if needed
|
||||||
*/
|
*/
|
||||||
const offers = useMemo(() => {
|
const offers = useMemo(() => {
|
||||||
if (!product) return [];
|
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
|
||||||
|
|
||||||
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[];
|
if (product?.buyUrl) {
|
||||||
if (raw.length > 0) return sortOffers(raw);
|
|
||||||
|
|
||||||
if (product.buyUrl) {
|
|
||||||
return sortOffers([
|
return sortOffers([
|
||||||
{
|
{
|
||||||
merchantName: product.merchantName ?? "Merchant",
|
merchantName: product.merchantName ?? "Retailer",
|
||||||
price: product.price,
|
price: product.price,
|
||||||
buyUrl: product.buyUrl,
|
buyUrl: product.buyUrl,
|
||||||
inStock: product.inStock ?? true,
|
inStock: product.inStock ?? true,
|
||||||
@@ -282,21 +344,30 @@ export default function ProductDetailsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}, [product]);
|
}, [offersFromApi, product]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best offer (for the "Buy from ..." CTA)
|
* Best offer (sorted offers[0])
|
||||||
* IMPORTANT: this must be defined AFTER offers is computed
|
|
||||||
* (do NOT reference `offers` inside the offers useMemo).
|
|
||||||
*/
|
*/
|
||||||
const bestOffer = offers[0] ?? null;
|
const bestOffer = offers[0] ?? null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: identify the best offer row
|
* Helper: identify the best offer row
|
||||||
* We compare by buyUrl (stable + unique enough for MVP)
|
|
||||||
*/
|
*/
|
||||||
const isBestOffer = (offer: OfferFromApi) =>
|
const isBestOffer = (offer: OfferFromApi) =>
|
||||||
!!bestOffer && offer.buyUrl && offer.buyUrl === bestOffer.buyUrl;
|
!!bestOffer &&
|
||||||
|
offer.id != null &&
|
||||||
|
bestOffer.id != null &&
|
||||||
|
String(offer.id) === String(bestOffer.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("OFFERS", offers);
|
||||||
|
console.log("BEST", bestOffer);
|
||||||
|
}, [offers, bestOffer]);
|
||||||
|
|
||||||
|
// Use best offer for “truth” when available
|
||||||
|
const stockValue = bestOffer?.inStock ?? product?.inStock ?? null;
|
||||||
|
const currentPrice = bestOffer?.price ?? product?.price ?? null;
|
||||||
|
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
return (
|
return (
|
||||||
@@ -366,35 +437,6 @@ export default function ProductDetailsPage() {
|
|||||||
canonical /parts/p route.
|
canonical /parts/p route.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform switch (updates NEW route) */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label
|
|
||||||
htmlFor="platform-select"
|
|
||||||
className="text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
Platform
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="platform-select"
|
|
||||||
value={platform}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value;
|
|
||||||
router.replace(
|
|
||||||
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
|
|
||||||
partRoleParam
|
|
||||||
)}/${encodeURIComponent(productSlug)}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
>
|
|
||||||
{PLATFORMS.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -414,18 +456,25 @@ export default function ProductDetailsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||||
{/* Left: image + core actions */}
|
{/* Left */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsImageOpen(true)}
|
||||||
|
className="h-full w-full"
|
||||||
|
title="Click to enlarge"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
className="h-full w-full object-contain p-2"
|
className="h-full w-full object-contain p-2"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||||
No image
|
No image
|
||||||
@@ -439,12 +488,12 @@ export default function ProductDetailsPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-semibold ${
|
className={`text-xs font-semibold ${
|
||||||
product.inStock === false
|
stockValue === false
|
||||||
? "text-red-300"
|
? "text-red-300"
|
||||||
: "text-emerald-300"
|
: "text-emerald-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{product.inStock === false ? "Out of stock" : "In stock"}
|
{stockValue === false ? "Out of stock" : "In stock"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -467,20 +516,18 @@ export default function ProductDetailsPage() {
|
|||||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSelected ? "Remove" : "Add"}
|
{isSelected ? "Remove" : "Add to Build"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing history placeholder */}
|
{/* Price history placeholder */}
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
Price History
|
Price History
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-zinc-600">
|
<span className="text-[10px] text-zinc-600">placeholder</span>
|
||||||
placeholder
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
|
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
|
||||||
Pricing graph will go here
|
Pricing graph will go here
|
||||||
@@ -492,7 +539,7 @@ export default function ProductDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: details + offers */}
|
{/* Right */}
|
||||||
<div className="min-w-0 space-y-4">
|
<div className="min-w-0 space-y-4">
|
||||||
{/* Product meta */}
|
{/* Product meta */}
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
@@ -532,7 +579,7 @@ export default function ProductDetailsPage() {
|
|||||||
Current price
|
Current price
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(currentPrice)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +607,11 @@ export default function ProductDetailsPage() {
|
|||||||
Merchant Offers
|
Merchant Offers
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-zinc-600">
|
<span className="text-[10px] text-zinc-600">
|
||||||
{offers.length ? `${offers.length} offers` : "no offers"}
|
{offersLoading
|
||||||
|
? "loading…"
|
||||||
|
: offers.length
|
||||||
|
? `${offers.length} offers`
|
||||||
|
: "no offers"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -578,20 +629,19 @@ export default function ProductDetailsPage() {
|
|||||||
<tbody className="divide-y divide-zinc-800">
|
<tbody className="divide-y divide-zinc-800">
|
||||||
{offers.map((o, idx) => (
|
{offers.map((o, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={idx}
|
key={String(o.id ?? idx)}
|
||||||
className={`hover:bg-zinc-900/40 ${
|
className={`hover:bg-zinc-900/40 ${
|
||||||
isBestOffer(o)
|
isBestOffer(o)
|
||||||
? "bg-amber-400/10 border-l-2 border-amber-400"
|
? "bg-amber-400/10 border-l-2 border-amber-400"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{" "}
|
|
||||||
<td className="px-3 py-2 text-zinc-200 flex items-center">
|
<td className="px-3 py-2 text-zinc-200 flex items-center">
|
||||||
<span>{o.merchantName ?? "Merchant"}</span>
|
<span>{merchantLabel(o)}</span>
|
||||||
|
|
||||||
{isBestOffer(o) && (
|
{isBestOffer(o) && (
|
||||||
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
|
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
|
||||||
BEST
|
BEST DEAL
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -607,9 +657,7 @@ export default function ProductDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
||||||
{o.price != null
|
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||||
? `$${o.price.toFixed(2)}`
|
|
||||||
: "—"}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{o.buyUrl ? (
|
{o.buyUrl ? (
|
||||||
@@ -622,9 +670,7 @@ export default function ProductDetailsPage() {
|
|||||||
Buy
|
Buy
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-zinc-600">
|
<span className="text-xs text-zinc-600">—</span>
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -639,7 +685,7 @@ export default function ProductDetailsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Best Offer CTA (uses sorted offers[0]) */}
|
{/* Best Offer CTA */}
|
||||||
{bestOffer?.buyUrl ? (
|
{bestOffer?.buyUrl ? (
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<a
|
<a
|
||||||
@@ -648,7 +694,7 @@ export default function ProductDetailsPage() {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
>
|
>
|
||||||
Buy from {bestOffer.merchantName ?? "Merchant"} →
|
Buy from {merchantLabel(bestOffer)} →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -656,20 +702,48 @@ export default function ProductDetailsPage() {
|
|||||||
|
|
||||||
{/* Footer tips */}
|
{/* Footer tips */}
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-zinc-500">
|
<div className="flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||||
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
{/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
Tip: Add this part, then go back and compare alternates.
|
Tip: Add this part, then go back and compare alternates.
|
||||||
</span>
|
</span> */}
|
||||||
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
{/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
Canonical route:{" "}
|
Canonical route:{" "}
|
||||||
<span className="text-zinc-300">
|
<span className="text-zinc-300">
|
||||||
/parts/p/{platform}/{partRoleParam}/{productSlug}
|
/parts/p/{platform}/{partRoleParam}/{productSlug}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Image modal */}
|
||||||
|
{isImageOpen && imageUrl ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
onClick={() => setIsImageOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative max-h-[90vh] max-w-[90vw]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsImageOpen(false)}
|
||||||
|
className="absolute -top-3 -right-3 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={product?.name ?? "Product image"}
|
||||||
|
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -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,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
|
export default function AdminBetaInvitesPage() {
|
||||||
|
const [dryRun, setDryRun] = useState(true);
|
||||||
|
const [limit, setLimit] = useState("1");
|
||||||
|
const [tokenMinutes, setTokenMinutes] = useState("30");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function runInvites() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
dryRun: String(dryRun),
|
||||||
|
limit: String(limit || "0"),
|
||||||
|
tokenMinutes: String(tokenMinutes || "30"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
|
||||||
|
|
||||||
|
setResult(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? "Failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Runs the backend batch invite sender (uses your email templates + tracking).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={dryRun}
|
||||||
|
onChange={(e) => setDryRun(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Dry run (do everything except actually send)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Field label="Limit" htmlFor="limit">
|
||||||
|
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||||
|
<Input
|
||||||
|
id="tokenMinutes"
|
||||||
|
value={tokenMinutes}
|
||||||
|
onChange={(e) => setTokenMinutes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={runInvites} disabled={loading}>
|
||||||
|
{loading ? "Running…" : "Send Beta Invites"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Check, X, Wand2, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED";
|
||||||
|
type EnrichmentType = "CALIBER" | "CALIBER_GROUP";
|
||||||
|
type EnrichmentSource = "RULES" | "AI";
|
||||||
|
|
||||||
|
type QueueItem = {
|
||||||
|
id: number;
|
||||||
|
productId: number;
|
||||||
|
productName?: string;
|
||||||
|
productSlug?: string;
|
||||||
|
mainImageUrl?: string;
|
||||||
|
brandName?: string;
|
||||||
|
enrichmentType: EnrichmentType;
|
||||||
|
source: EnrichmentSource;
|
||||||
|
status: EnrichmentStatus;
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
confidence: number;
|
||||||
|
rationale?: string;
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
// NEW: current value on products table (so we can prevent bad applies)
|
||||||
|
productCaliber?: string | null;
|
||||||
|
productCaliberGroup?: string | null;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
function getAuthHeaders(): HeadersInit {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
const token =
|
||||||
|
localStorage.getItem("token") ||
|
||||||
|
localStorage.getItem("jwt") ||
|
||||||
|
localStorage.getItem("accessToken");
|
||||||
|
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, init?: RequestInit) {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
// If you're using cookie auth instead of bearer, flip this on:
|
||||||
|
// credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(text || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
|
if (ct.includes("application/json")) return res.json();
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProductCaliber(it: QueueItem) {
|
||||||
|
const c = it.productCaliber;
|
||||||
|
return typeof c === "string" && c.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAlreadySet(it: QueueItem) {
|
||||||
|
if (it.enrichmentType === "CALIBER") {
|
||||||
|
return hasProductCaliber(it);
|
||||||
|
}
|
||||||
|
const g = it.productCaliberGroup;
|
||||||
|
return typeof g === "string" && g.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ADD THIS: text for the “Already set:” pill
|
||||||
|
function alreadySetLabel(it: QueueItem) {
|
||||||
|
return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnrichmentQueueClient() {
|
||||||
|
const [type, setType] = useState<EnrichmentType>("CALIBER");
|
||||||
|
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
|
||||||
|
const [limit, setLimit] = useState<number>(50);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<QueueItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const [selected, setSelected] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately
|
||||||
|
// so the row swaps buttons without waiting for a reload.
|
||||||
|
const [localStatus, setLocalStatus] = useState<
|
||||||
|
Record<number, EnrichmentStatus>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const count = useMemo(() => items.length, [items]);
|
||||||
|
|
||||||
|
const selectedIds = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(selected)
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k]) => Number(k)),
|
||||||
|
[selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const allOnPageSelected = useMemo(() => {
|
||||||
|
if (items.length === 0) return false;
|
||||||
|
return items.every((it) => selected[it.id]);
|
||||||
|
}, [items, selected]);
|
||||||
|
|
||||||
|
function toggleAllOnPage() {
|
||||||
|
const next = { ...selected };
|
||||||
|
const nextValue = !allOnPageSelected;
|
||||||
|
for (const it of items) next[it.id] = nextValue;
|
||||||
|
setSelected(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOne(id: number) {
|
||||||
|
setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveStatus(it: QueueItem): EnrichmentStatus {
|
||||||
|
return localStatus[it.id] ?? it.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = (await apiFetch(
|
||||||
|
`/api/admin/enrichment/queue2?type=${encodeURIComponent(
|
||||||
|
type
|
||||||
|
)}&status=${encodeURIComponent(status)}&limit=${limit}`
|
||||||
|
)) as QueueItem[];
|
||||||
|
|
||||||
|
setItems(data ?? []);
|
||||||
|
|
||||||
|
// Keep selection only for items still visible
|
||||||
|
setSelected((prev) => {
|
||||||
|
const keep = new Set((data ?? []).map((x) => x.id));
|
||||||
|
const next: Record<number, boolean> = {};
|
||||||
|
for (const [k, v] of Object.entries(prev)) {
|
||||||
|
const id = Number(k);
|
||||||
|
if (keep.has(id)) next[id] = v;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset local status for items not in view
|
||||||
|
setLocalStatus((prev) => {
|
||||||
|
const keep = new Set((data ?? []).map((x) => x.id));
|
||||||
|
const next: Record<number, EnrichmentStatus> = {};
|
||||||
|
for (const [k, v] of Object.entries(prev)) {
|
||||||
|
const id = Number(k);
|
||||||
|
if (keep.has(id)) next[id] = v;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? "Failed to load queue");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRules() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(
|
||||||
|
`/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
setStatus("PENDING_REVIEW");
|
||||||
|
setTimeout(load, 50);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? "Failed to run rules");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAi() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(
|
||||||
|
`/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
setStatus("PENDING_REVIEW");
|
||||||
|
setTimeout(load, 50);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? "Failed to run AI");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(id: number, action: "approve" | "reject" | "apply") {
|
||||||
|
setBusyId(id);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Optimistic UI for approve/reject so the row instantly changes
|
||||||
|
if (action === "approve")
|
||||||
|
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
|
||||||
|
if (action === "reject")
|
||||||
|
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
// rollback optimistic local status on error
|
||||||
|
setLocalStatus((p) => {
|
||||||
|
const next = { ...p };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError(e?.message ?? `Failed to ${action}`);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulk(action: "approve" | "reject" | "apply") {
|
||||||
|
const ids = selectedIds;
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply should only run for items that are APPROVED (effective status) AND product caliber is blank
|
||||||
|
const byId = new Map(items.map((x) => [x.id, x]));
|
||||||
|
const filtered =
|
||||||
|
action === "apply"
|
||||||
|
? ids.filter((id) => {
|
||||||
|
const it = byId.get(id);
|
||||||
|
if (!it) return false;
|
||||||
|
return (
|
||||||
|
effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: ids;
|
||||||
|
|
||||||
|
for (const id of filtered) {
|
||||||
|
// optimistic statuses for approve/reject in bulk
|
||||||
|
if (action === "approve")
|
||||||
|
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
|
||||||
|
if (action === "reject")
|
||||||
|
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
|
||||||
|
|
||||||
|
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelected({});
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? `Failed to bulk ${action}`);
|
||||||
|
setLoading(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [type, status, limit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="text-sm text-zinc-400">
|
||||||
|
Showing <span className="text-zinc-200 font-medium">{count}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as EnrichmentType)}
|
||||||
|
>
|
||||||
|
<option value="CALIBER">CALIBER</option>
|
||||||
|
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelected({});
|
||||||
|
setLocalStatus({});
|
||||||
|
setStatus(e.target.value as EnrichmentStatus);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
|
||||||
|
<option value="APPROVED">APPROVED</option>
|
||||||
|
<option value="REJECTED">REJECTED</option>
|
||||||
|
<option value="APPLIED">APPLIED</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runRules}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
||||||
|
disabled={loading}
|
||||||
|
title="Run enrichment rules to create new suggestions"
|
||||||
|
>
|
||||||
|
<Wand2 className="h-4 w-4" />
|
||||||
|
Run Rules
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runAi}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
|
||||||
|
disabled={loading}
|
||||||
|
title="Run AI enrichment to create new suggestions"
|
||||||
|
>
|
||||||
|
<Wand2 className="h-4 w-4" />
|
||||||
|
Run AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk action bar */}
|
||||||
|
{selectedIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
||||||
|
<div className="text-sm text-zinc-300">
|
||||||
|
Selected{" "}
|
||||||
|
<span className="font-semibold">{selectedIds.length}</span>
|
||||||
|
<span className="ml-2 text-xs text-zinc-500">
|
||||||
|
(Apply only works on APPROVED + product caliber blank)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bulk("approve")}
|
||||||
|
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Approve Selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bulk("reject")}
|
||||||
|
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Reject Selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bulk("apply")}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
||||||
|
disabled={loading}
|
||||||
|
title="Applies only APPROVED rows where product caliber is blank"
|
||||||
|
>
|
||||||
|
Apply Selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected({})}
|
||||||
|
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800">
|
||||||
|
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
className="h-4 w-4 accent-amber-400"
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>Product</div>
|
||||||
|
<div className="text-right">Confidence</div>
|
||||||
|
<div className="text-right">Suggested</div>
|
||||||
|
<div className="text-right">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-zinc-400">No items found.</div>
|
||||||
|
) : (
|
||||||
|
items.map((it) => {
|
||||||
|
const suggested =
|
||||||
|
it.enrichmentType === "CALIBER"
|
||||||
|
? it.attributes?.caliber
|
||||||
|
: it.attributes?.caliberGroup; const isBusy = busyId === it.id;
|
||||||
|
const st = effectiveStatus(it);
|
||||||
|
const alreadySet = hasAlreadySet(it);
|
||||||
|
const alreadySetValue = alreadySetLabel(it);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={it.id}
|
||||||
|
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!selected[it.id]}
|
||||||
|
onChange={() => toggleOne(it.id)}
|
||||||
|
className="h-4 w-4 accent-amber-400"
|
||||||
|
aria-label={`Select enrichment ${it.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{it.mainImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={it.mainImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-10 rounded border border-zinc-800 object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<a
|
||||||
|
href={`/admin/products/${it.productId}`}
|
||||||
|
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
|
||||||
|
>
|
||||||
|
{it.productName ?? `Product #${it.productId}`}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="mt-0.5 text-xs text-zinc-500">
|
||||||
|
{it.brandName ? `${it.brandName} • ` : ""}
|
||||||
|
{it.source} • {st}
|
||||||
|
{alreadySet ? (
|
||||||
|
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
|
||||||
|
Already set: {alreadySetValue}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{it.rationale ? (
|
||||||
|
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
|
||||||
|
{it.rationale}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right text-sm font-semibold text-zinc-200">
|
||||||
|
{(it.confidence ?? 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right text-sm text-amber-300">
|
||||||
|
{suggested ?? "—"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{st === "PENDING_REVIEW" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => act(it.id, "approve")}
|
||||||
|
disabled={isBusy || alreadySet}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
title={
|
||||||
|
alreadySet
|
||||||
|
? "Product caliber already set"
|
||||||
|
: "Approve"
|
||||||
|
}
|
||||||
|
aria-label="Approve"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => act(it.id, "reject")}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
title="Reject"
|
||||||
|
aria-label="Reject"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{st === "APPROVED" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => act(it.id, "apply")}
|
||||||
|
disabled={isBusy || alreadySet}
|
||||||
|
className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||||||
|
title={
|
||||||
|
alreadySet
|
||||||
|
? `Already set on product: ${alreadySetValue}`
|
||||||
|
: "Apply (writes to product if blank)"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{st === "APPLIED" && (
|
||||||
|
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
|
||||||
|
Applied
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{st === "REJECTED" && (
|
||||||
|
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
|
||||||
|
Rejected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+55
-60
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import AdminLeftNavigation from "@/components/AdminLeftNavigation";
|
import AdminLeftNavigation from "@/components/AdminLeftNavigation";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Download,
|
Download,
|
||||||
@@ -11,81 +14,72 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
LucideMail,
|
LucideMail,
|
||||||
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||||
label: "Dashboard",
|
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
|
||||||
href: "/admin",
|
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
|
||||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
|
||||||
},
|
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
|
||||||
{
|
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
|
||||||
label: "Imports",
|
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
|
||||||
href: "/admin/import-status",
|
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
|
||||||
icon: <Download className="h-4 w-4" />,
|
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
|
||||||
},
|
{ label: "List Emails", href: "/admin/email", icon: <LucideMail className="h-4 w-4" /> },
|
||||||
{
|
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
|
||||||
label: "Mappings",
|
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" /> },
|
||||||
href: "/admin/mapping",
|
|
||||||
icon: <Layers className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Products",
|
|
||||||
href: "/admin/products",
|
|
||||||
icon: <Boxes className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Merchants",
|
|
||||||
href: "/admin/merchants",
|
|
||||||
icon: <Store className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Platforms",
|
|
||||||
href: "/admin/platforms",
|
|
||||||
icon: <Layers className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Users",
|
|
||||||
href: "/admin/users",
|
|
||||||
icon: <Users className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Settings",
|
|
||||||
href: "/admin/settings",
|
|
||||||
icon: <Settings className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "List Emails",
|
|
||||||
href: "/admin/email",
|
|
||||||
icon: <LucideMail className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Send a Email",
|
|
||||||
href: "/admin/email/send",
|
|
||||||
icon: <LucideMail className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ... existing code ...
|
|
||||||
// ADMIN CHECK FOR LOGIN
|
|
||||||
// const { user, loading } = useAuth();
|
|
||||||
|
|
||||||
// if (!loading && user?.role !== "ADMIN") {
|
|
||||||
// redirect("/"); // or /login
|
|
||||||
// }
|
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Where to send people back after login
|
||||||
|
const next = useMemo(
|
||||||
|
() => encodeURIComponent(pathname || "/admin"),
|
||||||
|
[pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ AUTH GUARD
|
||||||
|
* Redirects happen in useEffect (NOT during render)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
// Not logged in
|
||||||
|
if (!user) {
|
||||||
|
router.replace(`/login?next=${next}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged in but not admin
|
||||||
|
if (user.role !== "ADMIN") {
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
}, [loading, user, router, next]);
|
||||||
|
|
||||||
|
// While loading OR redirecting, render nothing
|
||||||
|
if (loading) return null;
|
||||||
|
if (!user) return null;
|
||||||
|
if (user.role !== "ADMIN") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-black text-zinc-50">
|
<div className="flex min-h-screen bg-black text-zinc-50">
|
||||||
<AdminLeftNavigation
|
<AdminLeftNavigation
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||||
|
items={navItems}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* Main column */}
|
||||||
@@ -100,6 +94,7 @@ export default function AdminLayout({
|
|||||||
Battl Builders Control Panel
|
Battl Builders Control Panel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
|
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
|
||||||
Internal • v0.1
|
Internal • v0.1
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,61 +1,29 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
const BREVO_API_KEY = process.env.BREVO_API_KEY;
|
const API_BASE_URL =
|
||||||
const BREVO_LIST_ID = process.env.BREVO_LIST_ID; // optional
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
if (!BREVO_API_KEY) {
|
|
||||||
console.error("BREVO_API_KEY is not set");
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Server not configured" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { email } = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
if (!email || typeof email !== "string") {
|
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Valid email is required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brevo contacts API
|
|
||||||
const res = await fetch("https://api.brevo.com/v3/contacts", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify(body),
|
||||||
"api-key": BREVO_API_KEY,
|
cache: "no-store",
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
updateEnabled: true, // update if already exists
|
|
||||||
...(BREVO_LIST_ID
|
|
||||||
? { listIds: [Number(BREVO_LIST_ID)] }
|
|
||||||
: {}),
|
|
||||||
attributes: {
|
|
||||||
SOURCE: "Battl Builders Beta",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Always return ok=true (matches your server behavior)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
// Optional: log server response for debugging
|
||||||
console.error("Brevo error:", res.status, text);
|
const text = await res.text().catch(() => "");
|
||||||
return NextResponse.json(
|
console.error("beta-signup proxy failed:", res.status, text);
|
||||||
{ error: "Failed to subscribe" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
console.error(err);
|
console.error("beta-signup proxy error:", e);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ ok: true }); // keep “no enumeration”
|
||||||
{ error: "Something went wrong" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+219
-104
@@ -1,103 +1,182 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
/**
|
||||||
|
* app/builds/page.tsx
|
||||||
|
* Community Builds feed (public)
|
||||||
|
*
|
||||||
|
* Dev Notes:
|
||||||
|
* - We are moving to Option B:
|
||||||
|
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
|
||||||
|
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
|
||||||
|
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
|
||||||
|
*
|
||||||
|
* Backend (target contract):
|
||||||
|
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
|
||||||
|
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
type Caliber = "5.56" | "7.62" | "9mm" | ".300 BLK" | "12ga";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
|
||||||
|
* We can tighten once the controlled vocab is finalized.
|
||||||
|
*/
|
||||||
|
type Caliber = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This matches what the UI needs (card format).
|
||||||
|
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
|
||||||
|
*/
|
||||||
type BuildCard = {
|
type BuildCard = {
|
||||||
id: string;
|
id: string; // we use uuid here (public identifier)
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string; // can be uuid for now; keep property so UI does not change
|
||||||
creator: string;
|
creator: string; // placeholder until user profiles are real
|
||||||
caliber: Caliber;
|
caliber: Caliber;
|
||||||
buildClass: BuildClass;
|
buildClass: BuildClass;
|
||||||
price: number;
|
price: number; // optional server-side later; for now allow 0 fallback
|
||||||
votes: number;
|
votes: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
coverImageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DUMMY_BUILDS: BuildCard[] = [
|
type BuildFeedCardDto = {
|
||||||
{
|
uuid: string;
|
||||||
id: "1",
|
title?: string | null;
|
||||||
title: "Duty-Grade 12.5\" AR-15",
|
slug?: string | null; // optional (we can generate from title later)
|
||||||
slug: "duty-12-5-ar15",
|
creator?: string | null; // optional
|
||||||
creator: "quietpro_01",
|
caliber?: string | null;
|
||||||
caliber: "5.56",
|
buildClass?: BuildClass | null;
|
||||||
buildClass: "Rifle",
|
price?: number | null;
|
||||||
price: 2450,
|
votes?: number | null;
|
||||||
votes: 128,
|
tags?: string[] | null;
|
||||||
tags: ["Duty", "NV-Ready", "LPVO"],
|
coverImageUrl?: string | null;
|
||||||
},
|
};
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
title: "Home Defense PCC",
|
|
||||||
slug: "home-defense-pcc",
|
|
||||||
creator: "nerdgunner",
|
|
||||||
caliber: "9mm",
|
|
||||||
buildClass: "Pistol",
|
|
||||||
price: 1650,
|
|
||||||
votes: 74,
|
|
||||||
tags: ["Home Defense", "Red Dot", "Suppressor-Ready"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
title: "Short King .300 BLK SBR",
|
|
||||||
slug: "short-king-300blk-sbr",
|
|
||||||
creator: "subsonic_six",
|
|
||||||
caliber: ".300 BLK",
|
|
||||||
buildClass: "NFA",
|
|
||||||
price: 3125,
|
|
||||||
votes: 201,
|
|
||||||
tags: ["SBR", "Suppressed", "Night Work"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
title: "Budget 16\" Training Rifle",
|
|
||||||
slug: "budget-16-training-rifle",
|
|
||||||
creator: "range_rat",
|
|
||||||
caliber: "5.56",
|
|
||||||
buildClass: "Rifle",
|
|
||||||
price: 975,
|
|
||||||
votes: 53,
|
|
||||||
tags: ["Budget", "Trainer", "Recce-ish"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CALIBER_FILTERS: (Caliber | "all")[] = [
|
const API_BASE_URL =
|
||||||
"all",
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
"5.56",
|
|
||||||
"7.62",
|
|
||||||
"9mm",
|
|
||||||
".300 BLK",
|
|
||||||
"12ga",
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// --- UI Filter Sets (keep; we’ll generate options from data too) ---
|
||||||
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
||||||
|
|
||||||
const PRICE_FILTERS = [
|
const PRICE_FILTERS = [
|
||||||
{ id: "all", label: "All", min: 0, max: Infinity },
|
{ id: "all", label: "All", min: 0, max: Infinity },
|
||||||
{ id: "sub1k", label: "< $1k", min: 0, max: 1000 },
|
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
||||||
{ id: "1to2k", label: "$1k–$2k", min: 1000, max: 2000 },
|
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
||||||
{ id: "2to3k", label: "$2k–$3k", min: 2000, max: 3000 },
|
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
||||||
{ id: "3kplus", label: "$3k+", min: 3000, max: Infinity },
|
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function safeArray(v: unknown): string[] {
|
||||||
|
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackSlug(dto: BuildFeedCardDto) {
|
||||||
|
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
|
||||||
|
return dto.slug?.trim() || dto.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
|
||||||
|
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dto.uuid,
|
||||||
|
title,
|
||||||
|
slug: fallbackSlug(dto),
|
||||||
|
|
||||||
|
// Until we wire real users: keep a placeholder creator string.
|
||||||
|
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
||||||
|
|
||||||
|
caliber: (dto.caliber ?? "—").trim() || "—",
|
||||||
|
|
||||||
|
// Default to Rifle for display if missing; backend should set this via profile.
|
||||||
|
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
||||||
|
|
||||||
|
// Price is optional (we can compute later from BuildItems + offers).
|
||||||
|
price: typeof dto.price === "number" ? dto.price : 0,
|
||||||
|
|
||||||
|
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
||||||
|
|
||||||
|
tags: safeArray(dto.tags),
|
||||||
|
|
||||||
|
coverImageUrl: dto.coverImageUrl ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function BuildsPage() {
|
export default function BuildsPage() {
|
||||||
|
// --- Remote data state ---
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
||||||
|
|
||||||
|
// --- UI state (filters/votes) ---
|
||||||
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
const [votes, setVotes] = useState<Record<string, number>>(
|
|
||||||
Object.fromEntries(DUMMY_BUILDS.map((b) => [b.id, b.votes])),
|
|
||||||
);
|
|
||||||
|
|
||||||
const activePriceFilter = PRICE_FILTERS.find(
|
// Local vote state for optimistic UI
|
||||||
(p) => p.id === priceFilterId,
|
const [votes, setVotes] = useState<Record<string, number>>({});
|
||||||
) ?? PRICE_FILTERS[0];
|
|
||||||
|
const activePriceFilter =
|
||||||
|
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
||||||
|
|
||||||
|
// --- Fetch public builds feed ---
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as BuildFeedCardDto[];
|
||||||
|
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setBuilds(cards);
|
||||||
|
|
||||||
|
// Initialize vote UI from payload totals
|
||||||
|
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build the caliber filters dynamically from data, but keep "all" first.
|
||||||
|
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
||||||
|
const uniq = new Set<string>();
|
||||||
|
for (const b of builds) {
|
||||||
|
const c = (b.caliber ?? "").trim();
|
||||||
|
if (c && c !== "—") uniq.add(c);
|
||||||
|
}
|
||||||
|
return ["all", ...Array.from(uniq).sort()];
|
||||||
|
}, [builds]);
|
||||||
|
|
||||||
const filteredBuilds = useMemo(() => {
|
const filteredBuilds = useMemo(() => {
|
||||||
return DUMMY_BUILDS.filter((build) => {
|
return builds.filter((build) => {
|
||||||
const matchesCaliber =
|
const matchesCaliber =
|
||||||
caliberFilter === "all" || build.caliber === caliberFilter;
|
caliberFilter === "all" || build.caliber === caliberFilter;
|
||||||
|
|
||||||
@@ -110,14 +189,16 @@ export default function BuildsPage() {
|
|||||||
|
|
||||||
return matchesCaliber && matchesClass && matchesPrice;
|
return matchesCaliber && matchesClass && matchesPrice;
|
||||||
});
|
});
|
||||||
}, [caliberFilter, classFilter, activePriceFilter]);
|
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
||||||
|
|
||||||
const handleVote = (id: string, delta: 1 | -1) => {
|
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
||||||
|
// MVP: optimistic local vote only.
|
||||||
|
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
|
||||||
setVotes((prev) => ({
|
setVotes((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[id]: (prev[id] ?? 0) + delta,
|
[id]: (prev[id] ?? 0) + delta,
|
||||||
}));
|
}));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
@@ -136,11 +217,12 @@ export default function BuildsPage() {
|
|||||||
Community Builds
|
Community Builds
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
|
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
|
||||||
Browse community rifle builds, vote them up or down, and steal
|
Browse community builds, vote them up or down, and steal good
|
||||||
good ideas without shame. This is placeholder content for now —
|
ideas without shame. This feed is backed by public builds
|
||||||
real accounts, comments, and saved builds will wire in later.
|
(isPublic=true).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 md:gap-3">
|
<div className="flex gap-2 md:gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href="/builder"
|
||||||
@@ -148,15 +230,35 @@ export default function BuildsPage() {
|
|||||||
>
|
>
|
||||||
Open Builder
|
Open Builder
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
|
||||||
type="button"
|
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
||||||
|
<Link
|
||||||
|
href="/vault"
|
||||||
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
>
|
>
|
||||||
+ Submit Build (Coming Soon)
|
+ Submit Build
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||||
|
Loading community builds…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 text-xs text-red-200/80">
|
||||||
|
Dev tip: confirm backend route{" "}
|
||||||
|
<span className="font-mono">GET /api/v1/builds</span> is
|
||||||
|
implemented + CORS is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -165,21 +267,19 @@ export default function BuildsPage() {
|
|||||||
Filter Builds
|
Filter Builds
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
Filter by caliber, platform class, and ballpark price range.
|
Filter by caliber, class, and price range. (Client-side for
|
||||||
All logic is client-side placeholder until the real feed is wired
|
MVP.)
|
||||||
into the backend.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
Showing{" "}
|
Showing{" "}
|
||||||
<span className="text-zinc-200 font-medium">
|
<span className="text-zinc-200 font-medium">
|
||||||
{filteredBuilds.length}
|
{filteredBuilds.length}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
of{" "}
|
of{" "}
|
||||||
<span className="text-zinc-200 font-medium">
|
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||||
{DUMMY_BUILDS.length}
|
builds
|
||||||
</span>{" "}
|
|
||||||
demo builds
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -219,9 +319,7 @@ export default function BuildsPage() {
|
|||||||
<button
|
<button
|
||||||
key={cls}
|
key={cls}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
|
||||||
setClassFilter(cls === "all" ? "all" : cls)
|
|
||||||
}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
classFilter === cls
|
classFilter === cls
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
@@ -262,7 +360,10 @@ export default function BuildsPage() {
|
|||||||
{/* Build list */}
|
{/* Build list */}
|
||||||
<section>
|
<section>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredBuilds.map((build) => (
|
{filteredBuilds.map((build) => {
|
||||||
|
const dollars = Math.floor((build.price ?? 0) / 100);
|
||||||
|
|
||||||
|
return (
|
||||||
<article
|
<article
|
||||||
key={build.id}
|
key={build.id}
|
||||||
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
@@ -273,6 +374,8 @@ export default function BuildsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleVote(build.id, 1)}
|
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"
|
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>
|
</button>
|
||||||
@@ -283,18 +386,23 @@ export default function BuildsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleVote(build.id, -1)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
<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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src="https://placehold.co/320x200/png?text=Build+Photo"
|
src={
|
||||||
alt={`${build.title} placeholder`}
|
build.coverImageUrl ||
|
||||||
|
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
}
|
||||||
|
alt={`${build.title} cover`}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,16 +428,23 @@ export default function BuildsPage() {
|
|||||||
<span className="text-zinc-300">
|
<span className="text-zinc-300">
|
||||||
b/{build.creator}
|
b/{build.creator}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
· Placeholder build listing — details, parts list, and
|
· Build detail page will show the parts list +
|
||||||
comments will live on the build detail page later.
|
comments.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right mt-1 md:mt-0">
|
<div className="text-right mt-1 md:mt-0">
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
Est. Build Cost
|
Est. Build Cost
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold text-amber-300">
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
${build.price.toLocaleString()}
|
{build.price > 0 ? (
|
||||||
|
<span>
|
||||||
|
${Math.floor(build.price / 100).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,19 +458,19 @@ export default function BuildsPage() {
|
|||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
Comments, save, and share coming soon.
|
Comments + save coming soon.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{filteredBuilds.length === 0 && (
|
{!loading && !error && filteredBuilds.length === 0 && (
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
No builds match those filters yet. Once the real feed is wired
|
No builds match those filters yet.
|
||||||
up, this will update live as the community posts rifles, pistols,
|
|
||||||
and NFA builds.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,8 @@
|
|||||||
// app/layout.tsx
|
// app/layout.tsx
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { Banner } from "@/components/Banner";
|
import { Banner } from "@/components/Banner";
|
||||||
|
|
||||||
@@ -10,6 +12,12 @@ export const metadata = {
|
|||||||
template: "%s — Battl Builders",
|
template: "%s — Battl Builders",
|
||||||
},
|
},
|
||||||
description: "Build rifles smarter, not harder.",
|
description: "Build rifles smarter, not harder.",
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||||
|
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const themeScript = `
|
const themeScript = `
|
||||||
@@ -25,13 +33,16 @@ const themeScript = `
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning className="dark">
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body className="bg-white text-zinc-950 dark:bg-black dark:text-zinc-50">
|
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
|
||||||
{" "}
|
{" "}
|
||||||
|
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
/>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Banner />
|
<Banner />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+106
-5
@@ -1,12 +1,15 @@
|
|||||||
// app/login/page.tsx
|
// app/login/page.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -14,9 +17,18 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const next = searchParams.get("next") || "/builder";
|
// Magic link UI state (separate from password login)
|
||||||
|
const [magicLoading, setMagicLoading] = useState(false);
|
||||||
|
const [magicMessage, setMagicMessage] = useState<string | null>(null);
|
||||||
|
const [magicError, setMagicError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const next = useMemo(
|
||||||
|
() => searchParams.get("next") || "/builder",
|
||||||
|
[searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -26,7 +38,52 @@ export default function LoginPage() {
|
|||||||
await login({ email, password });
|
await login({ email, password });
|
||||||
router.push(next);
|
router.push(next);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? "Failed to log in");
|
setError(err?.message ?? "Failed to log in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMagicLinkRequest() {
|
||||||
|
setMagicMessage(null);
|
||||||
|
setMagicError(null);
|
||||||
|
|
||||||
|
const normalizedEmail = email.trim();
|
||||||
|
|
||||||
|
if (!normalizedEmail) {
|
||||||
|
setMagicError("Please enter your email above first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMagicLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ✅ This endpoint exists in your AuthController:
|
||||||
|
// POST /api/auth/beta/signup { email, useCase }
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: normalizedEmail,
|
||||||
|
useCase: "login_magic",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft-fail (avoid email enumeration)
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error("Magic link request failed:", res.status, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMagicMessage(
|
||||||
|
"If that email is eligible, 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +91,13 @@ export default function LoginPage() {
|
|||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Log In to <span className="text-amber-300">The Armory</span>
|
Log In to <span className="text-amber-300">The Builder</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
Use your beta credentials to get back to your saved builds.
|
Use your beta credentials to get back to your saved builds.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Password Login */}
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
@@ -74,7 +132,50 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-zinc-500">
|
{/* Divider */}
|
||||||
|
<div className="mt-8 flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-white/10" />
|
||||||
|
<span className="text-xs text-zinc-500">or</span>
|
||||||
|
<div className="h-px flex-1 bg-white/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Magic Link Request */}
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-zinc-200">
|
||||||
|
Already a beta user?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Request a one-time sign-in link (no password needed).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{magicError && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{magicError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{magicMessage && (
|
||||||
|
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
|
||||||
|
{magicMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={magicLoading}
|
||||||
|
onClick={handleMagicLinkRequest}
|
||||||
|
>
|
||||||
|
{magicLoading ? "Sending link…" : "Email me a sign-in link"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
Tip: check spam/promotions. Links expire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-8 text-xs text-zinc-500">
|
||||||
New here?{" "}
|
New here?{" "}
|
||||||
<Link href="/register" className="text-amber-300 hover:text-amber-200">
|
<Link href="/register" className="text-amber-300 hover:text-amber-200">
|
||||||
Join the beta
|
Join the beta
|
||||||
|
|||||||
+38
-12
@@ -13,6 +13,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
setMessage("Drop an email in first, operator.");
|
setMessage("Drop an email in first, operator.");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -30,11 +31,12 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Failed to save your signup.");
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(txt || "Failed to save your signup.");
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setMessage("You’re locked in. Watch your inbox.");
|
setMessage("✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s go-time.");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setUseCase("");
|
setUseCase("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -98,7 +100,7 @@ export default function HomePage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Image
|
<Image
|
||||||
src="/battl/battl-logo-mark-2.svg"
|
src="/battl/battl-logo-mark-f.svg"
|
||||||
alt="Battl Builders Logo"
|
alt="Battl Builders Logo"
|
||||||
width={260} // adjust to taste
|
width={260} // adjust to taste
|
||||||
height={48} // adjust to taste
|
height={48} // adjust to taste
|
||||||
@@ -160,7 +162,8 @@ export default function HomePage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@gearjunkie.com"
|
placeholder="you@gearjunkie.com"
|
||||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2"
|
disabled={status === "loading" || status === "success"}
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,17 +181,22 @@ export default function HomePage() {
|
|||||||
value={useCase}
|
value={useCase}
|
||||||
onChange={(e) => setUseCase(e.target.value)}
|
onChange={(e) => setUseCase(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
disabled={status === "loading" || status === "success"}
|
||||||
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..."
|
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..."
|
||||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2"
|
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === "loading"}
|
disabled={status === "loading" || status === "success"}
|
||||||
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{status === "loading" ? "Enlisting…" : "Get Beta Access"}
|
{status === "loading"
|
||||||
|
? "Enlisting…"
|
||||||
|
: status === "success"
|
||||||
|
? "On the List ✅"
|
||||||
|
: "Get Beta Access"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
@@ -205,10 +213,25 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="pt-1 text-[10px] leading-relaxed text-zinc-500">
|
{status === "success" && (
|
||||||
You can one-click unsubscribe any time with one-click. No data
|
<button
|
||||||
games, no surprise newsletters.
|
type="button"
|
||||||
</p>
|
onClick={() => {
|
||||||
|
setStatus("idle");
|
||||||
|
setMessage(null);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-900"
|
||||||
|
>
|
||||||
|
Submit another email
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-[11px] text-zinc-500">
|
||||||
|
Already invited?{" "}
|
||||||
|
<a href="/login" className="underline hover:text-zinc-300">
|
||||||
|
Log in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -216,7 +239,10 @@ export default function HomePage() {
|
|||||||
{/* Footer strip */}
|
{/* Footer strip */}
|
||||||
<footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500">
|
<footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<span>© {new Date().getFullYear()} BATTL BUILDERS<span className="align-super text-[0.9em] ml-0.5">™</span></span>
|
<span>
|
||||||
|
© {new Date().getFullYear()} BATTL BUILDERS
|
||||||
|
<span className="align-super text-[0.9em] ml-0.5">™</span>
|
||||||
|
</span>
|
||||||
<span className="text-zinc-600">
|
<span className="text-zinc-600">
|
||||||
v0.1 • Import engine online • Builder UI in progress
|
v0.1 • Import engine online • Builder UI in progress
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
// app/vault/page.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { useAuth } from "@/context/AuthContext";
|
|
||||||
|
|
||||||
type BuildDto = {
|
|
||||||
id: string;
|
|
||||||
uuid: string; // UUID string
|
|
||||||
title: string;
|
|
||||||
description?: string | null;
|
|
||||||
isPublic?: boolean | null;
|
|
||||||
createdAt?: string | null;
|
|
||||||
updatedAt?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
function fmt(d?: string | null) {
|
|
||||||
if (!d) return "—";
|
|
||||||
const dt = new Date(d);
|
|
||||||
if (Number.isNaN(dt.getTime())) return d;
|
|
||||||
return dt.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VaultPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { user, loading, getAuthHeaders } = useAuth();
|
|
||||||
|
|
||||||
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const authed = !!user;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && !authed) {
|
|
||||||
router.replace("/login");
|
|
||||||
}
|
|
||||||
}, [loading, authed, router]);
|
|
||||||
|
|
||||||
const headers = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading || !authed) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, {
|
|
||||||
method: "GET",
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text || `Request failed (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as BuildDto[];
|
|
||||||
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!cancelled) setError(e?.message || "Failed to load builds");
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setBusy(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [loading, authed, headers]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
|
||||||
<div className="text-sm opacity-70">Loading…</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not authed, we already redirected; render nothing.
|
|
||||||
if (!authed) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
|
||||||
<div className="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Vault</h1>
|
|
||||||
<p className="text-sm opacity-70">
|
|
||||||
Your saved builds. Load one into the builder or publish it later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/builder"
|
|
||||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
|
|
||||||
>
|
|
||||||
New Build
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/account"
|
|
||||||
className="text-sm opacity-80 hover:underline"
|
|
||||||
>
|
|
||||||
Account →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5">
|
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
|
||||||
<div className="text-sm font-medium">My Builds</div>
|
|
||||||
<div className="text-xs opacity-70">
|
|
||||||
{busy ? "Refreshing…" : `${builds.length} total`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{builds.length === 0 ? (
|
|
||||||
<div className="p-4 text-sm opacity-70">
|
|
||||||
No builds yet. Create one in the builder and hit “Save to Vault”.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-white/10">
|
|
||||||
{builds.map((b) => (
|
|
||||||
<li key={b.uuid} className="p-4 flex items-start justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium truncate">{b.title}</div>
|
|
||||||
<div className="mt-1 text-xs opacity-70">
|
|
||||||
Updated: {fmt(b.updatedAt)} • UUID:{" "}
|
|
||||||
<span className="font-mono">{b.uuid}</span>
|
|
||||||
</div>
|
|
||||||
{b.description ? (
|
|
||||||
<div className="mt-2 text-sm opacity-80 line-clamp-2">
|
|
||||||
{b.description}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{/* Placeholder: wire this to "load into builder" next */}
|
|
||||||
<Link
|
|
||||||
href={`/builder?load=${b.uuid}`}
|
|
||||||
|
|
||||||
// future href to support platform
|
|
||||||
// href={`/builder?platform=${encodeURIComponent(platform)}&load=${b.uuid}`}
|
|
||||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Download,
|
Download,
|
||||||
@@ -10,70 +11,41 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
LucideMail,
|
LucideMail,
|
||||||
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
|
export type AdminNavItem = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
type AdminLeftNavigationProps = {
|
type AdminLeftNavigationProps = {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
|
items?: AdminNavItem[]; // ✅ NEW
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
const defaultNavItems: AdminNavItem[] = [
|
||||||
{
|
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||||
label: "Dashboard",
|
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
|
||||||
href: "/admin",
|
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
|
||||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
|
||||||
},
|
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
|
||||||
{
|
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
|
||||||
label: "Imports",
|
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
|
||||||
href: "/admin/import-status",
|
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
|
||||||
icon: <Download className="h-4 w-4" />,
|
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
|
||||||
},
|
{ label: "Manage Emails", href: "/admin/email/manage", icon: <LucideMail className="h-4 w-4" /> },
|
||||||
{
|
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
|
||||||
label: "Mappings",
|
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" />,
|
||||||
href: "/admin/mapping",
|
|
||||||
icon: <Layers className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Products",
|
|
||||||
href: "/admin/products",
|
|
||||||
icon: <Boxes className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Merchants",
|
|
||||||
href: "/admin/merchants",
|
|
||||||
icon: <Store className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Platforms",
|
|
||||||
href: "/admin/platforms",
|
|
||||||
icon: <Layers className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Users",
|
|
||||||
href: "/admin/users",
|
|
||||||
icon: <Users className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Settings",
|
|
||||||
href: "/admin/settings",
|
|
||||||
icon: <Settings className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Manage Emails",
|
|
||||||
href: "/admin/email/manage",
|
|
||||||
icon: <LucideMail className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Send a Email",
|
|
||||||
href: "/admin/email/send",
|
|
||||||
icon: <LucideMail className="h-4 w-4" />,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLeftNavigation({
|
export default function AdminLeftNavigation({
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
|
items = defaultNavItems, // ✅ NEW default
|
||||||
}: AdminLeftNavigationProps) {
|
}: AdminLeftNavigationProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -98,7 +70,9 @@ export default function AdminLeftNavigation({
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
<Link href={"/"} title={"Back to Battl Dashboard"} >Battl Builders</Link>
|
<Link href="/" title="Back to Battl Dashboard">
|
||||||
|
Battl Builders
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-zinc-600">Admin Command</p>
|
<p className="text-[10px] text-zinc-600">Admin Command</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,8 +80,8 @@ export default function AdminLeftNavigation({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 text-sm">
|
<nav className="flex-1 space-y-1 text-sm">
|
||||||
{navItems.map((item) => (
|
{items.map((item) => (
|
||||||
<a
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
|
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
|
||||||
@@ -117,15 +91,13 @@ export default function AdminLeftNavigation({
|
|||||||
{!collapsed && <span>{item.label}</span>}
|
{!collapsed && <span>{item.label}</span>}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && <span className="text-[10px] text-zinc-600">›</span>}
|
{!collapsed && <span className="text-[10px] text-zinc-600">›</span>}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
|
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
|
||||||
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
|
||||||
Status
|
|
||||||
</p>
|
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
Import engine: <span className="text-emerald-400">online</span>
|
Import engine: <span className="text-emerald-400">online</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function Banner() {
|
|||||||
Early Access Beta
|
Early Access Beta
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden text-amber-100/90 md:inline">
|
<span className="hidden text-amber-100/90 md:inline">
|
||||||
You're using an early-access prototype of The Armory. Data,
|
You're using an early-access prototype of the Builder. Data,
|
||||||
pricing, and available parts are still evolving.
|
pricing, and available parts are still evolving.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
const renderDropdown = (label: string, items: Category[]) => {
|
const renderDropdown = (label: string, items: Category[]) => {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
const platform = searchParams.get("platform");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
{/* Dropdown trigger */}
|
{/* Dropdown trigger */}
|
||||||
@@ -80,7 +82,10 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
return (
|
return (
|
||||||
<li key={cat.id}>
|
<li key={cat.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`${baseHref}/${cat.id}`}
|
href={{
|
||||||
|
pathname: `${baseHref}/${cat.id}`,
|
||||||
|
query: platform ? { platform } : {},
|
||||||
|
}}
|
||||||
className={`block w-full px-3 py-1.5 transition-colors ${
|
className={`block w-full px-3 py-1.5 transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-neutral-800 text-white"
|
? "bg-neutral-800 text-white"
|
||||||
@@ -162,7 +167,9 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
{/* "Viewing" indicator (only shows when a category is active) */}
|
{/* "Viewing" indicator (only shows when a category is active) */}
|
||||||
{currentCategory && (
|
{currentCategory && (
|
||||||
<div className="flex items-center gap-2 text-neutral-400 text-xs">
|
<div className="flex items-center gap-2 text-neutral-400 text-xs">
|
||||||
<span className="uppercase tracking-wide text-[10px]">Viewing</span>
|
<span className="uppercase tracking-wide text-[10px]">
|
||||||
|
Viewing
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className="font-medium text-neutral-100">
|
<span className="font-medium text-neutral-100">
|
||||||
{CATEGORIES.find((c) => c.id === currentCategory)?.name ??
|
{CATEGORIES.find((c) => c.id === currentCategory)?.name ??
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PriceHistoryPoint } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data";
|
import type { PriceHistoryPoint } from "@/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data";
|
||||||
|
|
||||||
interface PricingHistoryGraphProps {
|
interface PricingHistoryGraphProps {
|
||||||
data: PriceHistoryPoint[];
|
data: PriceHistoryPoint[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RetailerOffer } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data";
|
import type { RetailerOffer } from "@/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data";
|
||||||
|
|
||||||
interface RetailersListProps {
|
interface RetailersListProps {
|
||||||
retailers: RetailerOffer[];
|
retailers: RetailerOffer[];
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ export default function ThemeToggle() {
|
|||||||
document.documentElement.classList.toggle("dark", next === "dark");
|
document.documentElement.classList.toggle("dark", next === "dark");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @TODO, the toggle doesn't look right, need to either fix or remove the toggle.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme" title={`Toggle to ${theme === 'dark' ? 'light' : 'dark'}`}
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 transition-colors
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 transition-colors
|
||||||
dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-200
|
dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-200
|
||||||
border-zinc-300 bg-white text-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
border-zinc-300 bg-white text-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import ThemeToggle from "@/components/ThemeToggle";
|
|||||||
function NavLink({
|
function NavLink({
|
||||||
href,
|
href,
|
||||||
children,
|
children,
|
||||||
|
title
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const active = pathname === href;
|
const active = pathname === href;
|
||||||
@@ -21,6 +23,7 @@ function NavLink({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
|
title={title}
|
||||||
className={[
|
className={[
|
||||||
"text-xs font-medium transition-colors",
|
"text-xs font-medium transition-colors",
|
||||||
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
|
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
|
||||||
@@ -44,7 +47,7 @@ export function TopNav() {
|
|||||||
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
|
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/battl/battl-logo-mark-2.svg"
|
src="/battl/battl-logo-mark-f.svg"
|
||||||
alt="Battl Builders Logo"
|
alt="Battl Builders Logo"
|
||||||
width={260}
|
width={260}
|
||||||
height={48}
|
height={48}
|
||||||
@@ -60,7 +63,7 @@ export function TopNav() {
|
|||||||
<span className="text-xs text-zinc-500">Checking session…</span>
|
<span className="text-xs text-zinc-500">Checking session…</span>
|
||||||
) : isAuthenticated ? (
|
) : isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<NavLink href="/vault">Vault</NavLink>
|
<NavLink href="/vault" title={"Your builds"}>Vault</NavLink>
|
||||||
|
|
||||||
{/* Email/displayName links to Account */}
|
{/* Email/displayName links to Account */}
|
||||||
<Link
|
<Link
|
||||||
@@ -96,3 +99,4 @@ export function TopNav() {
|
|||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export default TopNav;
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PartsBrowseClient
|
* PartsBrowseClient
|
||||||
* -----------------------------------------------------------------------------
|
* -----------------------------------------------------------------------------
|
||||||
@@ -13,8 +8,11 @@ import { usePathname } from "next/navigation";
|
|||||||
* - layout + list/card views
|
* - layout + list/card views
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import Filters from "@/components/parts/Filters";
|
import Filters from "@/components/parts/Filters";
|
||||||
import SortBar from "@/components/parts/SortBar";
|
import SortBar from "@/components/parts/SortBar";
|
||||||
@@ -49,7 +47,7 @@ type UiPart = {
|
|||||||
brand: string;
|
brand: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
partRole: string;
|
partRole: string;
|
||||||
price: number; // normalized
|
price: number;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
buyUrl?: string;
|
buyUrl?: string;
|
||||||
inStock?: boolean;
|
inStock?: boolean;
|
||||||
@@ -57,6 +55,7 @@ type UiPart = {
|
|||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
function normalizeId(id: string | number) {
|
function normalizeId(id: string | number) {
|
||||||
@@ -92,8 +91,13 @@ export default function PartsBrowseClient(props: {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// ✅ Builder-mode = /parts/p/... (detail routes)
|
||||||
|
const isBuilderMode = pathname.startsWith("/parts/p/");
|
||||||
|
|
||||||
|
// ✅ Respect prop override first, else read query, else fallback
|
||||||
const effectivePlatform =
|
const effectivePlatform =
|
||||||
props.platform ?? searchParams.get("platform") ?? "AR-15";
|
props.platform ?? searchParams.get("platform") ?? "AR-15";
|
||||||
|
|
||||||
const partRole = props.partRole;
|
const partRole = props.partRole;
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
@@ -103,7 +107,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
|
|
||||||
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
||||||
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [priceRange, setPriceRange] = useState<{
|
const [priceRange, setPriceRange] = useState<{
|
||||||
min: number | null;
|
min: number | null;
|
||||||
max: number | null;
|
max: number | null;
|
||||||
@@ -114,6 +118,9 @@ export default function PartsBrowseClient(props: {
|
|||||||
const [inStockOnly, setInStockOnly] = useState(false);
|
const [inStockOnly, setInStockOnly] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Fetch parts
|
||||||
|
// ----------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!partRole) return;
|
if (!partRole) return;
|
||||||
|
|
||||||
@@ -128,14 +135,17 @@ export default function PartsBrowseClient(props: {
|
|||||||
search.set("platform", effectivePlatform);
|
search.set("platform", effectivePlatform);
|
||||||
search.append("partRoles", partRole);
|
search.append("partRoles", partRole);
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/api/v1/products?${search.toString()}`;
|
const res = await fetch(
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
`${API_BASE_URL}/api/v1/products?${search.toString()}`,
|
||||||
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
||||||
|
|
||||||
const data: GunbuilderProductFromApi[] = await res.json();
|
const data: GunbuilderProductFromApi[] = await res.json();
|
||||||
|
|
||||||
const normalized: UiPart[] = data.map((p) => ({
|
setParts(
|
||||||
|
data.map((p) => ({
|
||||||
id: normalizeId(p.id),
|
id: normalizeId(p.id),
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand,
|
brand: p.brand,
|
||||||
@@ -145,9 +155,8 @@ export default function PartsBrowseClient(props: {
|
|||||||
imageUrl: p.mainImageUrl ?? undefined,
|
imageUrl: p.mainImageUrl ?? undefined,
|
||||||
buyUrl: p.buyUrl ?? undefined,
|
buyUrl: p.buyUrl ?? undefined,
|
||||||
inStock: p.inStock ?? true,
|
inStock: p.inStock ?? true,
|
||||||
}));
|
}))
|
||||||
|
);
|
||||||
setParts(normalized);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
setError(err?.message ?? "Failed to load products");
|
setError(err?.message ?? "Failed to load products");
|
||||||
@@ -160,6 +169,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [partRole, effectivePlatform]);
|
}, [partRole, effectivePlatform]);
|
||||||
|
|
||||||
|
// Reset pagination on filters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [
|
}, [
|
||||||
@@ -172,11 +182,14 @@ export default function PartsBrowseClient(props: {
|
|||||||
inStockOnly,
|
inStockOnly,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Reset scroll on route change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset scroll position whenever we land on or change parts routes
|
window.scrollTo({ top: 0, behavior: "auto" });
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Derived values
|
||||||
|
// ----------------------------
|
||||||
const availableBrands = useMemo(
|
const availableBrands = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Array.from(new Set(parts.map((p) => p.brand)))
|
Array.from(new Set(parts.map((p) => p.brand)))
|
||||||
@@ -186,34 +199,30 @@ export default function PartsBrowseClient(props: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const priceBounds = useMemo(() => {
|
const priceBounds = useMemo(() => {
|
||||||
if (parts.length === 0)
|
if (!parts.length)
|
||||||
return { min: null as number | null, max: null as number | null };
|
return { min: null as number | null, max: null as number | null };
|
||||||
|
return {
|
||||||
let min = Number.POSITIVE_INFINITY;
|
min: Math.min(...parts.map((p) => p.price)),
|
||||||
let max = Number.NEGATIVE_INFINITY;
|
max: Math.max(...parts.map((p) => p.price)),
|
||||||
|
};
|
||||||
for (const p of parts) {
|
|
||||||
const price = p.price ?? 0;
|
|
||||||
if (price < min) min = price;
|
|
||||||
if (price > max) max = price;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
||||||
return { min: null as number | null, max: null as number | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { min, max };
|
|
||||||
}, [parts]);
|
}, [parts]);
|
||||||
|
|
||||||
|
// Keep priceRange clamped to bounds when bounds change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (priceBounds.min == null || priceBounds.max == null) return;
|
const minBound = priceBounds.min;
|
||||||
|
const maxBound = priceBounds.max;
|
||||||
|
if (minBound == null || maxBound == null) return;
|
||||||
|
|
||||||
setPriceRange((prev) => {
|
setPriceRange((prev) => {
|
||||||
const min = prev.min ?? priceBounds.min!;
|
const nextMin =
|
||||||
const max = prev.max ?? priceBounds.max!;
|
prev.min == null ? minBound : Math.max(minBound, prev.min);
|
||||||
|
const nextMax =
|
||||||
|
prev.max == null ? maxBound : Math.min(maxBound, prev.max);
|
||||||
|
|
||||||
|
// Ensure min never exceeds max (in case user typed weird values)
|
||||||
return {
|
return {
|
||||||
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
|
min: Math.min(nextMin, nextMax),
|
||||||
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
|
max: Math.max(nextMin, nextMax),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [priceBounds.min, priceBounds.max]);
|
}, [priceBounds.min, priceBounds.max]);
|
||||||
@@ -221,30 +230,22 @@ export default function PartsBrowseClient(props: {
|
|||||||
const filteredParts = useMemo(() => {
|
const filteredParts = useMemo(() => {
|
||||||
let result = [...parts];
|
let result = [...parts];
|
||||||
|
|
||||||
const effectiveMin = priceRange.min ?? priceBounds.min;
|
if (priceRange.min != null)
|
||||||
const effectiveMax = priceRange.max ?? priceBounds.max;
|
result = result.filter((p) => p.price >= priceRange.min!);
|
||||||
if (effectiveMin != null && effectiveMax != null) {
|
if (priceRange.max != null)
|
||||||
result = result.filter(
|
result = result.filter((p) => p.price <= priceRange.max!);
|
||||||
(p) => p.price >= effectiveMin && p.price <= effectiveMax
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (brandFilter.length > 0) {
|
if (brandFilter.length)
|
||||||
result = result.filter((p) => brandFilter.includes(p.brand));
|
result = result.filter((p) => brandFilter.includes(p.brand));
|
||||||
}
|
|
||||||
|
|
||||||
if (inStockOnly) {
|
if (inStockOnly) result = result.filter((p) => p.inStock ?? true);
|
||||||
result = result.filter((p) => p.inStock ?? true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.toLowerCase().trim();
|
||||||
if (q) {
|
if (q)
|
||||||
result = result.filter((p) => {
|
result = result.filter(
|
||||||
const name = (p.name ?? "").toLowerCase();
|
(p) =>
|
||||||
const brand = (p.brand ?? "").toLowerCase();
|
p.name.toLowerCase().includes(q) || p.brand.toLowerCase().includes(q)
|
||||||
return name.includes(q) || brand.includes(q);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case "price-asc":
|
case "price-asc":
|
||||||
@@ -262,47 +263,25 @@ export default function PartsBrowseClient(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [
|
}, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
|
||||||
parts,
|
|
||||||
brandFilter,
|
|
||||||
sortBy,
|
|
||||||
searchQuery,
|
|
||||||
priceRange,
|
|
||||||
priceBounds.min,
|
|
||||||
priceBounds.max,
|
|
||||||
inStockOnly,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const totalPages = useMemo(
|
const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE));
|
||||||
() =>
|
const paginatedParts = filteredParts.slice(
|
||||||
filteredParts.length === 0
|
(currentPage - 1) * PAGE_SIZE,
|
||||||
? 1
|
currentPage * PAGE_SIZE
|
||||||
: Math.ceil(filteredParts.length / PAGE_SIZE),
|
|
||||||
[filteredParts.length]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const paginatedParts = useMemo(() => {
|
const visibleRange = {
|
||||||
if (filteredParts.length === 0) return [];
|
start: filteredParts.length ? (currentPage - 1) * PAGE_SIZE + 1 : 0,
|
||||||
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
end: Math.min(currentPage * PAGE_SIZE, filteredParts.length),
|
||||||
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
};
|
||||||
}, [filteredParts, currentPage]);
|
|
||||||
|
|
||||||
const visibleRange = useMemo(() => {
|
|
||||||
if (filteredParts.length === 0) return { start: 0, end: 0 };
|
|
||||||
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
|
||||||
const end = Math.min(
|
|
||||||
start + paginatedParts.length - 1,
|
|
||||||
filteredParts.length
|
|
||||||
);
|
|
||||||
return { start, end };
|
|
||||||
}, [filteredParts.length, currentPage, paginatedParts.length]);
|
|
||||||
|
|
||||||
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
|
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
|
||||||
const headingSubtitle =
|
const headingSubtitle = props.subtitle ?? "Browse available parts.";
|
||||||
props.subtitle ??
|
|
||||||
"Browse available parts pulled from your Ballistic backend.";
|
|
||||||
|
|
||||||
// ✅ Add-to-build handler: deep-links into builder’s existing ?select= logic
|
// ----------------------------
|
||||||
|
// Add → Builder handoff
|
||||||
|
// ----------------------------
|
||||||
const handleAddToBuild = (p: UiPart) => {
|
const handleAddToBuild = (p: UiPart) => {
|
||||||
const normalizedRole = normalizePartRole(partRole);
|
const normalizedRole = normalizePartRole(partRole);
|
||||||
const categoryId: CategoryId | null =
|
const categoryId: CategoryId | null =
|
||||||
@@ -327,14 +306,13 @@ export default function PartsBrowseClient(props: {
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
<header className="mb-6">
|
<header className="mb-6 flex justify-between gap-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
Battl Builders
|
Battl Builders
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold">
|
||||||
{headingTitle}{" "}
|
{headingTitle}{" "}
|
||||||
<span className="text-amber-300">{effectivePlatform}</span>
|
<span className="text-amber-300">{effectivePlatform}</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -343,29 +321,39 @@ export default function PartsBrowseClient(props: {
|
|||||||
{headingSubtitle}
|
{headingSubtitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-3">
|
{!isBuilderMode && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
<PlatformSwitcher
|
<PlatformSwitcher
|
||||||
currentPlatform={effectivePlatform}
|
currentPlatform={effectivePlatform}
|
||||||
partRole={partRole}
|
partRole={partRole}
|
||||||
preserveQuery
|
preserveQuery
|
||||||
|
mode="browse"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!loading && !error && (
|
{isBuilderMode && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={`/builder?platform=${encodeURIComponent(
|
||||||
pathname: "/builder",
|
effectivePlatform
|
||||||
query: effectivePlatform
|
)}`}
|
||||||
? { platform: effectivePlatform }
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold hover:bg-zinc-800"
|
||||||
: {},
|
|
||||||
}}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800"
|
|
||||||
>
|
>
|
||||||
← Back to Build
|
← Back to Build
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Optional: view toggle in builder-mode too */}
|
||||||
{parts.length > 0 && (
|
{parts.length > 0 && (
|
||||||
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
|
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
|
||||||
<button
|
<button
|
||||||
@@ -394,13 +382,12 @@ export default function PartsBrowseClient(props: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
{!loading && !error && parts.length > 0 && (
|
{parts.length > 0 && (
|
||||||
<aside className="w-full md:w-64 shrink-0">
|
<aside className="w-full md:w-64">
|
||||||
<Filters
|
<Filters
|
||||||
partRoleLabel={partRole}
|
partRoleLabel={partRole}
|
||||||
availableBrands={availableBrands}
|
availableBrands={availableBrands}
|
||||||
@@ -416,7 +403,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{!loading && !error && parts.length > 0 && (
|
{!loading && !error && (
|
||||||
<SortBar
|
<SortBar
|
||||||
visibleRange={visibleRange}
|
visibleRange={visibleRange}
|
||||||
totalCount={filteredParts.length}
|
totalCount={filteredParts.length}
|
||||||
@@ -432,9 +419,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
Loading…
|
Loading…
|
||||||
</p>
|
</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="py-8 text-center text-sm text-red-400">
|
<p className="py-8 text-center text-sm text-red-400">{error}</p>
|
||||||
{error} — check that the Ballistic API is running.
|
|
||||||
</p>
|
|
||||||
) : filteredParts.length === 0 ? (
|
) : filteredParts.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-zinc-500">
|
<p className="py-8 text-center text-sm text-zinc-500">
|
||||||
No parts found for this role yet.
|
No parts found for this role yet.
|
||||||
@@ -451,10 +436,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading &&
|
{totalPages > 1 && (
|
||||||
!error &&
|
|
||||||
filteredParts.length > 0 &&
|
|
||||||
totalPages > 1 && (
|
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
type UiPart = {
|
type UiPart = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
|
caliber?: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
partRole: string;
|
partRole: string;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -36,7 +38,8 @@ export default function PartsGrid(props: {
|
|||||||
<div className="mb-3 flex items-center justify-between gap-2">
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-zinc-50">
|
<div className="truncate text-sm font-semibold text-zinc-50">
|
||||||
{part.brand} <span className="font-normal">— {part.name}</span>
|
{part.brand}{" "}
|
||||||
|
<span className="font-normal">— {part.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,8 +92,9 @@ export default function PartsGrid(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header (DESKTOP ONLY) */}
|
{/* Header (DESKTOP ONLY) */}
|
||||||
<div className="hidden md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_260px] md:items-center md:gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500"> <span className="min-w-0">Part</span>
|
<div className="hidden md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
<span className="min-w-0">Brand</span>
|
<span className="min-w-0">Part</span>
|
||||||
|
<span className="text-right">Caliber</span>
|
||||||
<span className="text-right">Price</span>
|
<span className="text-right">Price</span>
|
||||||
<span className="text-right">Actions</span>
|
<span className="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,17 +106,27 @@ export default function PartsGrid(props: {
|
|||||||
key={part.id}
|
key={part.id}
|
||||||
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
|
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_260px] md:items-center md:gap-4">
|
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4">
|
||||||
{/* Part */}
|
{/* Part */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-zinc-50">
|
<Link
|
||||||
|
href={buildDetailHref(part)}
|
||||||
|
className="block text-sm font-semibold text-zinc-50 leading-snug line-clamp-2 break-words hover:text-amber-300 transition-colors"
|
||||||
|
title={part.name}
|
||||||
|
>
|
||||||
{part.name}
|
{part.name}
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
{/* Dont think we need Brand in the grid */}
|
||||||
|
{/* <div className="mt-0.5 text-xs text-zinc-500 line-clamp-1">
|
||||||
{/* Brand */}
|
|
||||||
<div className="min-w-0 text-xs text-zinc-400 md:text-sm">
|
|
||||||
{part.brand}
|
{part.brand}
|
||||||
|
{part.caliber ? (
|
||||||
|
<span className="text-zinc-600"> • {part.caliber}</span>
|
||||||
|
) : null}
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
{/* Caliber (desktop) */}
|
||||||
|
<div className="hidden md:block text-sm text-zinc-300 text-right">
|
||||||
|
{part.caliber ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
@@ -122,20 +136,15 @@ export default function PartsGrid(props: {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link
|
|
||||||
href={buildDetailHref(part)}
|
|
||||||
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:border-zinc-600 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{onAddToBuild ? (
|
{onAddToBuild ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAddToBuild(part)}
|
onClick={() => onAddToBuild(part)}
|
||||||
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
className="inline-flex items-center justify-center h-8 w-8 rounded-md bg-amber-400 text-black hover:bg-amber-300 transition-colors"
|
||||||
|
aria-label="Add to Build"
|
||||||
|
title="Add to Build"
|
||||||
>
|
>
|
||||||
{addLabel}
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
) : part.buyUrl ? (
|
) : part.buyUrl ? (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,60 +1,75 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
type Platform = (typeof PLATFORMS)[number];
|
||||||
|
|
||||||
/**
|
|
||||||
* PlatformSwitcher
|
|
||||||
*
|
|
||||||
* Works on BOTH:
|
|
||||||
* - /parts/[partRole] (default browse route)
|
|
||||||
* - /parts/[platform]/[partRole] (canonical route)
|
|
||||||
*
|
|
||||||
* Behavior:
|
|
||||||
* - If you're on /parts/[partRole], switching platform navigates to /parts/[platform]/[partRole]
|
|
||||||
* (so your URL becomes explicit/shareable).
|
|
||||||
* - If you're already on /parts/[platform]/[partRole], it swaps the platform segment.
|
|
||||||
*/
|
|
||||||
export default function PlatformSwitcher(props: {
|
export default function PlatformSwitcher(props: {
|
||||||
currentPlatform: string;
|
currentPlatform: string;
|
||||||
partRole: string;
|
partRole: string;
|
||||||
// If true, we keep query params when switching (nice for sort/paging)
|
/**
|
||||||
|
* If true, preserve *all* existing query params (except platform will be overwritten).
|
||||||
|
* Useful when you want to keep things like ?from=builder or future filters.
|
||||||
|
*/
|
||||||
preserveQuery?: boolean;
|
preserveQuery?: boolean;
|
||||||
|
/**
|
||||||
|
* Force route behavior.
|
||||||
|
* - "browse": /parts/[role]?platform=...
|
||||||
|
* - "detail": /parts/p/[platform]/[role]
|
||||||
|
* Default: auto based on current pathname
|
||||||
|
*/
|
||||||
|
mode?: "browse" | "detail";
|
||||||
}) {
|
}) {
|
||||||
const { currentPlatform, partRole, preserveQuery = true } = props;
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const queryString = useMemo(() => {
|
const inferredMode: "browse" | "detail" = pathname.startsWith("/parts/p/")
|
||||||
if (!preserveQuery) return "";
|
? "detail"
|
||||||
const q = searchParams?.toString();
|
: "browse";
|
||||||
return q ? `?${q}` : "";
|
|
||||||
}, [searchParams, preserveQuery]);
|
|
||||||
|
|
||||||
function go(platform: string) {
|
const mode = props.mode ?? inferredMode;
|
||||||
// We always navigate to canonical to keep it simple & consistent
|
|
||||||
router.push(`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}${queryString}`);
|
const buildHref = (nextPlatform: Platform) => {
|
||||||
|
if (mode === "detail") {
|
||||||
|
return `/parts/p/${encodeURIComponent(nextPlatform)}/${encodeURIComponent(
|
||||||
|
props.partRole
|
||||||
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// browse mode => keep on /parts/[role] and just change the query param
|
||||||
<div className="flex items-center gap-2">
|
const qp = props.preserveQuery
|
||||||
<span className="text-xs uppercase tracking-[0.14em] text-zinc-500">Platform</span>
|
? new URLSearchParams(searchParams.toString())
|
||||||
<select
|
: new URLSearchParams();
|
||||||
value={currentPlatform}
|
|
||||||
onChange={(e) => go(e.target.value)}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
|
||||||
>
|
|
||||||
<option value="AR-15">AR-15</option>
|
|
||||||
<option value="AR-10">AR-10</option>
|
|
||||||
<option value="AR-9">AR-9</option>
|
|
||||||
<option value="AK-47">AK-47</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<span className="ml-2 text-[0.7rem] text-zinc-500">
|
qp.set("platform", nextPlatform);
|
||||||
{pathname}
|
|
||||||
</span>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,11 +122,12 @@ export default function ProductDetailPageClient(props: {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlatformSwitcher currentPlatform={platform} partRole={partRole} />
|
{/* temp disabling while I rework the routing. */}
|
||||||
|
{/* <PlatformSwitcher currentPlatform={platform} partRole={partRole} /> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||||
<Link className="hover:text-amber-200" href={`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}>
|
<Link className="hover:text-amber-200" href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}>
|
||||||
← Back to list
|
← Back to list
|
||||||
</Link>
|
</Link>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
|
|||||||
+81
-34
@@ -13,6 +13,7 @@ const API_BASE_URL =
|
|||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
type AuthUser = {
|
type AuthUser = {
|
||||||
|
uuid: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -30,6 +31,12 @@ type AuthContextValue = {
|
|||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
getAuthHeaders: () => HeadersInit;
|
getAuthHeaders: () => HeadersInit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for non-password auth flows (ex: magic link).
|
||||||
|
* Persists session exactly like login/register.
|
||||||
|
*/
|
||||||
|
setSession: (token: string, user: NonNullable<AuthUser>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
@@ -42,30 +49,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [user, setUser] = useState<AuthUser>(null);
|
const [user, setUser] = useState<AuthUser>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
// Hydrate from localStorage on first load
|
/**
|
||||||
useEffect(() => {
|
* Persist auth to localStorage
|
||||||
if (typeof window === "undefined") return;
|
*/
|
||||||
|
const persistAuth = useCallback(
|
||||||
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
(nextToken: string | null, nextUser: AuthUser) => {
|
||||||
const storedUser = window.localStorage.getItem(USER_KEY);
|
|
||||||
|
|
||||||
if (storedToken) {
|
|
||||||
setToken(storedToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storedUser) {
|
|
||||||
try {
|
|
||||||
setUser(JSON.parse(storedUser));
|
|
||||||
} catch {
|
|
||||||
// bad JSON? wipe it
|
|
||||||
window.localStorage.removeItem(USER_KEY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const persistAuth = useCallback((nextToken: string | null, nextUser: AuthUser) => {
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
if (nextToken) {
|
if (nextToken) {
|
||||||
@@ -79,8 +67,67 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(USER_KEY);
|
window.localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Hydrate from localStorage ONCE
|
||||||
|
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
||||||
|
const storedUser = window.localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
|
if (storedToken && storedUser) {
|
||||||
|
try {
|
||||||
|
const parsedUser = JSON.parse(storedUser);
|
||||||
|
|
||||||
|
setToken(storedToken);
|
||||||
|
setUser(parsedUser);
|
||||||
|
|
||||||
|
// ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
|
||||||
|
fetch("/api/auth/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: storedToken,
|
||||||
|
role: parsedUser?.role,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
|
window.localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by login/register/magic-link
|
||||||
|
*/
|
||||||
|
const setSession = useCallback(
|
||||||
|
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||||
|
setToken(nextToken);
|
||||||
|
setUser(nextUser);
|
||||||
|
persistAuth(nextToken, nextUser);
|
||||||
|
|
||||||
|
// ✅ Make middleware / server aware of the session
|
||||||
|
fetch("/api/auth/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: nextToken,
|
||||||
|
role: nextUser.role,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
[persistAuth]
|
||||||
|
);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async ({ email, password }: { email: string; password: string }) => {
|
async ({ email, password }: { email: string; password: string }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -98,24 +145,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Adjust these to match your backend response shape
|
|
||||||
const nextToken: string = data.token ?? data.accessToken;
|
const nextToken: string = data.token ?? data.accessToken;
|
||||||
const nextUser: AuthUser =
|
const nextUser: AuthUser =
|
||||||
data.user ??
|
data.user ??
|
||||||
({
|
({
|
||||||
|
uuid: data.uuid,
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? null,
|
displayName: data.displayName ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
} as AuthUser);
|
} as AuthUser);
|
||||||
|
|
||||||
setToken(nextToken);
|
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
setUser(nextUser);
|
|
||||||
persistAuth(nextToken, nextUser);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[persistAuth]
|
[setSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
@@ -147,25 +192,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const nextUser: AuthUser =
|
const nextUser: AuthUser =
|
||||||
data.user ??
|
data.user ??
|
||||||
({
|
({
|
||||||
|
uuid: data.uuid,
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? displayName ?? null,
|
displayName: data.displayName ?? displayName ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
} as AuthUser);
|
} as AuthUser);
|
||||||
|
|
||||||
setToken(nextToken);
|
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
setUser(nextUser);
|
|
||||||
persistAuth(nextToken, nextUser);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[persistAuth]
|
[setSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
persistAuth(null, null);
|
persistAuth(null, null);
|
||||||
|
|
||||||
|
// Clear server session cookies
|
||||||
|
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
|
||||||
}, [persistAuth]);
|
}, [persistAuth]);
|
||||||
|
|
||||||
const getAuthHeaders = useCallback((): HeadersInit => {
|
const getAuthHeaders = useCallback((): HeadersInit => {
|
||||||
@@ -180,12 +227,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
|
setSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔑 This is what your useApi hook imports
|
|
||||||
export function useAuth(): AuthContextValue {
|
export function useAuth(): AuthContextValue {
|
||||||
const ctx = useContext(AuthContext);
|
const ctx = useContext(AuthContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
|
|||||||
+48
-11
@@ -1,26 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type JsonValue = any;
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
async function get(path: string) {
|
return useMemo(() => {
|
||||||
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
|
function authHeaders(extra?: HeadersInit): HeadersInit {
|
||||||
headers: {
|
const base: Record<string, string> = {};
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
if (token) base.Authorization = `Bearer ${token}`;
|
||||||
},
|
return { ...base, ...(extra as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path: string, init?: RequestInit) {
|
||||||
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: authHeaders(init?.headers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(path: string, body: any) {
|
async function post(path: string, body: JsonValue, init?: RequestInit) {
|
||||||
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
...init,
|
||||||
|
headers: authHeaders({
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
...(init?.headers as any),
|
||||||
},
|
}),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { get, post };
|
async function put(path: string, body: JsonValue, init?: RequestInit) {
|
||||||
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method: "PUT",
|
||||||
|
...init,
|
||||||
|
headers: authHeaders({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers as any),
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(path: string, init?: RequestInit) {
|
||||||
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
...init,
|
||||||
|
headers: authHeaders(init?.headers),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ stable reference unless token changes
|
||||||
|
return { get, post, put, del };
|
||||||
|
}, [token]);
|
||||||
}
|
}
|
||||||
+34
-9
@@ -12,15 +12,40 @@ const ALWAYS_ALLOW = new Set<string>([
|
|||||||
"/sitemap.xml",
|
"/sitemap.xml",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Match common public/static file extensions so middleware never blocks them
|
const PUBLIC_FILE =
|
||||||
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
|
/\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
|
|
||||||
|
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
// ✅ Always allow Next internals + static assets (public/ and _next/)
|
console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT);
|
||||||
|
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 1) Admin gate (always on)
|
||||||
|
// =========================
|
||||||
|
if (pathname.startsWith("/admin")) {
|
||||||
|
const token = req.cookies.get("bb_access_token")?.value;
|
||||||
|
const role = req.cookies.get("bb_role")?.value; // optional
|
||||||
|
|
||||||
|
const ok = !!token && (!role || role === "ADMIN");
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================
|
||||||
|
// 2) Launch-only gate (your existing one)
|
||||||
|
// =====================================
|
||||||
|
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
|
||||||
|
|
||||||
|
// ✅ Always allow Next internals + static assets
|
||||||
if (
|
if (
|
||||||
pathname.startsWith("/_next") ||
|
pathname.startsWith("/_next") ||
|
||||||
pathname.startsWith("/favicon") ||
|
pathname.startsWith("/favicon") ||
|
||||||
@@ -31,17 +56,17 @@ export function middleware(req: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Allow the root + a few public files
|
|
||||||
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
// 🚫 Everything else: hide it
|
|
||||||
return NextResponse.rewrite(new URL("/404", req.url));
|
return NextResponse.rewrite(new URL("/404", req.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
` 1`
|
||||||
export const config = {
|
export const config = {
|
||||||
// Run middleware on everything *except* Next internals and obvious static files.
|
|
||||||
// This keeps launch gating from ever breaking your logo/images/fonts/etc.
|
|
||||||
matcher: [
|
matcher: [
|
||||||
|
// run on everything except Next internals + obvious static files
|
||||||
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)",
|
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)",
|
||||||
],
|
],
|
||||||
|
|
||||||
};
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1133.86 425.2">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
fill-rule: evenodd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #febe2a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Page_1" data-name="Page 1">
|
||||||
|
<g id="Icon">
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M314.03,138.62c4.07,8.49,3.1,16.88-2.79,24.22l-39.94,49.76,39.94,49.76c5.89,7.34,6.86,15.74,2.79,24.22-4.07,8.49-11.22,12.99-20.64,12.99h-91.92v42.9h91.92c25.92,0,48.11-13.97,59.32-37.34s8.21-49.42-8.02-69.63l-18.38-22.9,18.38-22.9c16.23-20.21,19.23-46.26,8.02-69.63s-33.4-37.34-59.32-37.34h-91.92v42.9h91.92c9.41,0,16.57,4.5,20.64,12.99h0Z"/>
|
||||||
|
<polygon class="cls-2" points="271.3 212.6 236.9 169.76 181.89 169.76 216.28 212.6 181.89 255.44 236.9 255.44 271.3 212.6"/>
|
||||||
|
<polygon class="cls-2" points="146.46 125.63 201.48 125.63 167.04 82.73 57 82.73 126.87 169.76 181.89 169.76 146.46 125.63"/>
|
||||||
|
<path class="cls-2" d="M150.79,199.56c-14.45-3.22-31.32-5.34-47.86-5.34h-33.96v13.15l-59.88,5.22,59.88,5.22v13.15h33.96c16.54,0,33.41-2.12,47.86-5.34,12.64-2.81,23.43-6.46,30.53-10.27l.08-2.77-.08-2.77c-7.11-3.8-17.9-7.46-30.53-10.27h0Z"/>
|
||||||
|
<polygon class="cls-2" points="181.89 255.44 126.87 255.44 57 342.47 167.04 342.47 201.48 299.56 146.46 299.56 181.89 255.44"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="BattlBuilders_text" data-name="BattlBuilders text">
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M478.75,241.24c-4.01-13.79-16.75-23.88-31.84-23.88h-50.03v125.1h52.33c18.36-.08,33.02-15,33.02-33.16v-5.76c0-11.16-5.51-21.02-13.96-27.04,7.86-6.63,11.8-15.78,11.8-26,0-3.22-.46-6.34-1.32-9.28h0ZM425.57,241.24h15.07c5.93,0,10.73,4.81,10.73,10.73v1.91c0,5.93-4.81,10.73-10.73,10.73h-15.07v-23.37h0ZM425.57,288.49h17.23c5.93,0,10.73,4.81,10.73,10.73v8.64c0,5.95-4.8,10.71-10.73,10.73h-17.23v-30.1h0Z"/>
|
||||||
|
<polygon class="cls-1" points="588.81 217.37 588.81 342.47 617.5 342.47 617.5 217.37 588.81 217.37"/>
|
||||||
|
<polygon class="cls-1" points="629.21 217.37 629.21 342.47 703.46 342.47 703.46 318.59 657.89 318.59 657.89 217.37 629.21 217.37"/>
|
||||||
|
<path class="cls-1" d="M712.99,217.37v125.1h52.05c18.35,0,33.29-14.68,33.29-33.16v-58.79c0-18.21-14.74-33.15-33.15-33.15h-52.19ZM741.68,318.59v-77.35h17.23c5.9,0,10.69,4.76,10.73,10.65v56.04c-.04,5.96-4.87,10.65-10.81,10.65h-17.15Z"/>
|
||||||
|
<polygon class="cls-1" points="810.04 217.37 810.04 342.47 886.77 342.47 886.77 318.59 838.73 318.59 838.73 291.85 876.86 291.85 876.86 267.98 838.73 267.98 838.73 241.24 886.77 241.24 886.77 217.37 810.04 217.37"/>
|
||||||
|
<path class="cls-1" d="M993.35,250.52c.29,10.37,1.8,16.6,18.51,28.4l29.13,20.82c4.72,3.37,7.36,5.55,7.36,9.26,0,4.95-5.22,9.6-10.63,9.6h-4.95c-5.9,0-10.69-4.76-10.73-10.65v-4.84h-28.69v6.21c0,3.22.46,6.33,1.32,9.28,4,13.74,16.68,23.88,31.84,23.88h17.48c15.16,0,27.83-10.13,31.84-23.88.86-2.95,1.32-6.06,1.32-9.28-.29-10.37-1.8-16.6-18.51-28.4l-29.13-20.82c-4.72-3.37-7.36-5.55-7.36-9.26,0-4.96,5.22-9.6,10.63-9.6h4.95c5.9,0,10.69,4.76,10.73,10.65v4.84h28.69v-6.21c0-3.22-.46-6.33-1.32-9.28-4-13.74-16.68-23.88-31.84-23.88h-17.48c-15.16,0-27.83,10.13-31.84,23.88-.86,2.95-1.32,6.06-1.32,9.28h0Z"/>
|
||||||
|
<path class="cls-1" d="M981.64,250.52c0-18.21-14.74-33.15-33.16-33.15h-52.19v125.1h28.69v-43.33h17.16c5.97,0,10.81,4.74,10.81,10.73v22.34c0,4.75.91,8.65,2.42,10.27h28.69c-1.52-1.61-2.43-5.52-2.43-10.27v-18.74c0-10.31-4.76-19.97-12.92-26.27,8.17-6.3,12.92-15.96,12.92-26.27v-10.41h0ZM942.22,241.24c5.93,0,10.73,4.81,10.73,10.73v12.55c0,5.99-4.84,10.73-10.81,10.73h-17.16v-34.02h17.24Z"/>
|
||||||
|
<path class="cls-1" d="M548.42,217.37v93.21h-2.51v3.84h2.51v4.17h-27.97v-4.17h2.51v-3.84h-2.51v-93.21h-28.69v91.95c0,18.16,14.66,33.08,33.02,33.16h19.31c15.1-.06,27.71-10.18,31.7-23.88.86-2.95,1.32-6.06,1.32-9.28v-91.95h-28.69Z"/>
|
||||||
|
<path class="cls-1" d="M478.75,106.61c-4.01-13.79-16.75-23.88-31.84-23.88h-50.03v125.1h52.33c18.36-.07,33.02-15,33.02-33.15v-5.76c0-11.16-5.51-21.02-13.96-27.04,7.86-6.63,11.8-15.78,11.8-26,0-3.22-.46-6.34-1.32-9.28h0ZM425.57,106.61h15.07c5.93,0,10.73,4.81,10.73,10.73v1.91c0,5.93-4.81,10.73-10.73,10.73h-15.07v-23.37h0ZM425.57,153.85h17.23c5.93,0,10.73,4.81,10.73,10.73v8.64c0,5.95-4.8,10.71-10.73,10.73h-17.23v-30.1h0Z"/>
|
||||||
|
<path class="cls-1" d="M535.69,97.25c5.99,13.8,8.73,28.29,8.73,41.27v25.48h-19.97v-25.48c0-12.98,2.74-27.48,8.73-41.27h2.51ZM577.1,115.89c0-18.16-14.66-33.08-33.02-33.15h-19.31c-18.36.07-33.02,15-33.02,33.15v91.95h28.69v-10.03l2.56-11.48v-13.17h22.85v13.17l2.56,11.48v10.03h28.69v-91.95h0Z"/>
|
||||||
|
<polygon class="cls-1" points="766.9 82.73 766.9 207.83 841.16 207.83 841.16 183.96 795.59 183.96 795.59 82.73 766.9 82.73"/>
|
||||||
|
<polygon class="cls-1" points="757.37 82.73 669.97 82.73 669.97 106.6 699.33 106.6 699.33 207.83 728.01 207.83 728.01 106.6 757.37 106.6 757.37 82.73"/>
|
||||||
|
<polygon class="cls-1" points="660.44 106.6 660.44 82.73 571.56 82.73 585.34 106.6 602.39 106.6 602.39 207.83 631.08 207.83 631.08 106.6 660.44 106.6"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 624 B |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350.31 259.74">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #febe2a;
|
||||||
|
fill-rule: evenodd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Page_1" data-name="Page 1">
|
||||||
|
<g id="Icon">
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M304.94,55.89c4.07,8.49,3.1,16.88-2.79,24.22l-39.94,49.75,39.94,49.75c5.89,7.34,6.86,15.74,2.79,24.22-4.07,8.49-11.22,12.99-20.63,12.99h-91.92v42.9h91.92c25.92,0,48.11-13.97,59.32-37.34,11.21-23.37,8.21-49.42-8.02-69.63l-18.38-22.9,18.38-22.9c16.23-20.21,19.23-46.26,8.02-69.63C332.41,13.97,310.22,0,284.3,0h-91.92v42.9h91.92c9.41,0,16.57,4.5,20.63,12.99h0Z"/>
|
||||||
|
<polygon class="cls-1" points="262.2 129.87 227.81 87.03 172.79 87.03 207.19 129.87 172.79 172.71 227.81 172.71 262.2 129.87 262.2 129.87"/>
|
||||||
|
<polygon class="cls-1" points="137.37 42.9 192.39 42.9 157.94 0 47.91 0 117.77 87.03 172.79 87.03 137.37 42.9 137.37 42.9"/>
|
||||||
|
<path class="cls-1" d="M141.7,116.83c-14.45-3.22-31.32-5.34-47.86-5.34h-33.96v13.15L0,129.87l59.88,5.22v13.15h33.96c16.54,0,33.41-2.12,47.86-5.34,12.64-2.81,23.43-6.46,30.53-10.27l.08-2.77-.08-2.77c-7.11-3.8-17.9-7.46-30.53-10.27h0Z"/>
|
||||||
|
<polygon class="cls-1" points="172.79 172.71 117.77 172.71 47.91 259.74 157.94 259.74 192.39 216.83 137.37 216.83 172.79 172.71 172.79 172.71"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
Reference in New Issue
Block a user