lots of fixes. cant remember them all
This commit is contained in:
@@ -0,0 +1,482 @@
|
|||||||
|
//
|
||||||
|
// This page is most likely temporary. We probably do not need this page
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Pencil,
|
||||||
|
Link2,
|
||||||
|
Square,
|
||||||
|
CheckSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
const UNIVERSAL_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"magazine",
|
||||||
|
"weapon-light",
|
||||||
|
"foregrip",
|
||||||
|
"bipod",
|
||||||
|
"sling",
|
||||||
|
"rail-accessory",
|
||||||
|
"tools",
|
||||||
|
"optic",
|
||||||
|
"sights",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CATEGORY_GROUPS: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
categoryIds: CategoryId[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: "lower-group",
|
||||||
|
label: "Lower Receiver Parts",
|
||||||
|
description:
|
||||||
|
"Everything from the serialized lower to small parts, fire control, and core controls.",
|
||||||
|
categoryIds: [
|
||||||
|
"lower-receiver",
|
||||||
|
"complete-lower",
|
||||||
|
"lower-parts",
|
||||||
|
"trigger",
|
||||||
|
"grip",
|
||||||
|
"safety",
|
||||||
|
"buffer",
|
||||||
|
"stock",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "upper-group",
|
||||||
|
label: "Upper Receiver Parts",
|
||||||
|
description:
|
||||||
|
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
|
||||||
|
categoryIds: [
|
||||||
|
"upper-receiver",
|
||||||
|
"complete-upper",
|
||||||
|
"bcg",
|
||||||
|
"barrel",
|
||||||
|
"gas-block",
|
||||||
|
"gas-tube",
|
||||||
|
"muzzle-device",
|
||||||
|
"suppressor",
|
||||||
|
"handguard",
|
||||||
|
"charging-handle",
|
||||||
|
"sights",
|
||||||
|
"optic",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accessories-group",
|
||||||
|
label: "Accessories & Gear",
|
||||||
|
description:
|
||||||
|
"Universal add-ons that work across platforms — mags, lights, slings, tools, and more.",
|
||||||
|
categoryIds: [
|
||||||
|
"magazine",
|
||||||
|
"weapon-light",
|
||||||
|
"foregrip",
|
||||||
|
"bipod",
|
||||||
|
"sling",
|
||||||
|
"rail-accessory",
|
||||||
|
"tools",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function isValidPlatform(
|
||||||
|
value: string | null
|
||||||
|
): value is (typeof PLATFORMS)[number] {
|
||||||
|
return !!value && (PLATFORMS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecodeBuildParam(buildParam: string | null): BuildState | null {
|
||||||
|
if (!buildParam) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// build param is base64(json)
|
||||||
|
const json = window.atob(buildParam);
|
||||||
|
const parsed = JSON.parse(json) as BuildState;
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(n: number) {
|
||||||
|
return `$${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildSummaryPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const qpPlatform = searchParams.get("platform");
|
||||||
|
const platform = isValidPlatform(qpPlatform) ? qpPlatform : "AR-15";
|
||||||
|
|
||||||
|
const [build, setBuild] = useState<BuildState>({});
|
||||||
|
const [parts, setParts] = useState<Part[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load build from ?build=... OR localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const buildParam = searchParams.get("build");
|
||||||
|
const decoded = safeDecodeBuildParam(buildParam);
|
||||||
|
|
||||||
|
if (decoded) {
|
||||||
|
setBuild(decoded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
|
||||||
|
} catch {
|
||||||
|
setBuild({});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch product data so we can resolve ids -> Part info
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchProducts() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// scoped (platform)
|
||||||
|
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
|
||||||
|
platform
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
// universal (optional)
|
||||||
|
const universalUrl = `${API_BASE_URL}/api/products`;
|
||||||
|
|
||||||
|
const [scopedRes, universalRes] = await Promise.all([
|
||||||
|
fetch(scopedUrl, { signal: controller.signal }),
|
||||||
|
fetch(universalUrl, { signal: controller.signal }).catch(
|
||||||
|
() => null as any
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!scopedRes || !scopedRes.ok) {
|
||||||
|
throw new Error(`Failed to load products (${scopedRes?.status ?? ""})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedData: GunbuilderProductFromApi[] = await scopedRes.json();
|
||||||
|
|
||||||
|
let universalData: GunbuilderProductFromApi[] = [];
|
||||||
|
if (universalRes && universalRes.ok) {
|
||||||
|
universalData = await universalRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: We only need enough to render the summary.
|
||||||
|
// We will map “everything we can” and ignore unknown categories.
|
||||||
|
const toCategoryId = (rawRole: string): CategoryId | null => {
|
||||||
|
const role = (rawRole ?? "").trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
|
||||||
|
// quick defensive mapping (mirrors your builder page strategy)
|
||||||
|
if (role.includes("complete-upper")) return "complete-upper";
|
||||||
|
if (role.includes("complete-lower")) return "complete-lower";
|
||||||
|
if (role === "upper-receiver" || role === "upper") return "upper-receiver";
|
||||||
|
if (role === "lower-receiver" || role === "lower") return "lower-receiver";
|
||||||
|
if (role.includes("charging")) return "charging-handle";
|
||||||
|
if (role.includes("handguard")) return "handguard";
|
||||||
|
if (role.includes("bcg") || role.includes("bolt-carrier")) return "bcg";
|
||||||
|
if (role.includes("barrel")) return "barrel";
|
||||||
|
if (role.includes("gas-block") || role.includes("gasblock")) return "gas-block";
|
||||||
|
if (role.includes("gas-tube") || role.includes("gastube")) return "gas-tube";
|
||||||
|
if (role.includes("muzzle") || role.includes("flash") || role.includes("brake") || role.includes("comp"))
|
||||||
|
return "muzzle-device";
|
||||||
|
if (role.includes("suppress")) return "suppressor";
|
||||||
|
if (role.includes("lower-parts") || role.includes("lpk")) return "lower-parts";
|
||||||
|
if (role.includes("trigger")) return "trigger";
|
||||||
|
if (role.includes("grip")) return "grip";
|
||||||
|
if (role.includes("safety")) return "safety";
|
||||||
|
if (role.includes("buffer")) return "buffer";
|
||||||
|
if (role.includes("stock")) return "stock";
|
||||||
|
if (role.includes("optic") || role.includes("scope")) return "optic";
|
||||||
|
if (role.includes("sight")) return "sights";
|
||||||
|
if (role.includes("mag")) return "magazine";
|
||||||
|
if (role.includes("weapon-light") || (role.includes("light") && !role.includes("flight")))
|
||||||
|
return "weapon-light";
|
||||||
|
if (role.includes("foregrip") || role.includes("grip-vertical")) return "foregrip";
|
||||||
|
if (role.includes("bipod")) return "bipod";
|
||||||
|
if (role.includes("sling")) return "sling";
|
||||||
|
if (role.includes("rail-accessory") || role.includes("rail-attachment")) return "rail-accessory";
|
||||||
|
if (role.includes("tool")) return "tools";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize = (data: GunbuilderProductFromApi[], isUniversal: boolean): Part[] =>
|
||||||
|
data
|
||||||
|
.map((p): Part | null => {
|
||||||
|
const categoryId = toCategoryId(p.partRole);
|
||||||
|
if (!categoryId) return null;
|
||||||
|
|
||||||
|
if (isUniversal && !UNIVERSAL_CATEGORIES.has(categoryId)) return null;
|
||||||
|
|
||||||
|
const buyUrl = p.buyUrl ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(p.id),
|
||||||
|
categoryId,
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
imageUrl: (p.imageUrl as any) ?? (p as any).mainImageUrl ?? undefined,
|
||||||
|
affiliateUrl: buyUrl,
|
||||||
|
url: buyUrl,
|
||||||
|
notes: undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p): p is Part => p !== null);
|
||||||
|
|
||||||
|
const scopedParts = normalize(scopedData, false);
|
||||||
|
const universalParts = normalize(universalData, true);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: Part[] = [];
|
||||||
|
for (const p of [...scopedParts, ...universalParts]) {
|
||||||
|
const key = `${p.categoryId}:${p.id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
setParts(merged);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load products");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProducts();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
|
const summaryCategoryOrder: CategoryId[] = useMemo(
|
||||||
|
() =>
|
||||||
|
CATEGORY_GROUPS.flatMap((g) => g.categoryIds).filter((id) =>
|
||||||
|
CATEGORIES.some((c) => c.id === id)
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedParts = useMemo(() => {
|
||||||
|
const out: { categoryId: CategoryId; part?: Part }[] = [];
|
||||||
|
for (const cid of summaryCategoryOrder) {
|
||||||
|
const partId = build[cid];
|
||||||
|
const part = partId ? parts.find((p) => p.id === partId && p.categoryId === cid) : undefined;
|
||||||
|
out.push({ categoryId: cid, part });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [build, parts, summaryCategoryOrder]);
|
||||||
|
|
||||||
|
const total = useMemo(
|
||||||
|
() => selectedParts.reduce((sum, row) => sum + (row.part?.price ?? 0), 0),
|
||||||
|
[selectedParts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filledCount = useMemo(
|
||||||
|
() => selectedParts.filter((r) => !!r.part).length,
|
||||||
|
[selectedParts]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
BATTL BUILDERS
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Build <span className="text-amber-300">Summary</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
|
Snapshot of your current selections. (V1: data resolves from your local build state + live products.)
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 text-xs text-zinc-500">
|
||||||
|
Platform: <span className="text-zinc-200 font-semibold">{platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Total
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-amber-300">
|
||||||
|
{formatPrice(total)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">
|
||||||
|
{filledCount} / {summaryCategoryOrder.length} selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform);
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Back to Builder
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Edit Build
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">Loading…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-10 text-center text-sm text-red-400">
|
||||||
|
{error} — check that the Ballistic API is running.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{CATEGORY_GROUPS.map((group) => {
|
||||||
|
const rows = group.categoryIds
|
||||||
|
.filter((cid) => summaryCategoryOrder.includes(cid))
|
||||||
|
.map((cid) => {
|
||||||
|
const cat = CATEGORIES.find((c) => c.id === cid);
|
||||||
|
const partId = build[cid];
|
||||||
|
const part = partId
|
||||||
|
? parts.find((p) => p.id === partId && p.categoryId === cid)
|
||||||
|
: undefined;
|
||||||
|
return { cid, cat, part };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||||
|
<div className="border-b border-zinc-800 px-4 py-3">
|
||||||
|
<div className="text-sm font-semibold text-zinc-100">{group.label}</div>
|
||||||
|
{group.description && (
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{group.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-zinc-900">
|
||||||
|
{rows.map(({ cid, cat, part }) => (
|
||||||
|
<div key={cid} className="flex items-start justify-between gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-semibold text-zinc-300">
|
||||||
|
{cat?.name ?? cid}
|
||||||
|
</div>
|
||||||
|
{part ? (
|
||||||
|
<div className="mt-1 text-sm text-zinc-100 truncate">
|
||||||
|
<span className="text-zinc-400">{part.brand}</span>{" "}
|
||||||
|
{part.name}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-sm text-zinc-600">
|
||||||
|
Not selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-xs text-zinc-500">Price</div>
|
||||||
|
<div className={`text-sm font-semibold ${part ? "text-amber-300" : "text-zinc-600"}`}>
|
||||||
|
{part ? formatPrice(part.price) : "—"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: `/parts/p/${platform}/${cid}`,
|
||||||
|
query: platform ? { platform } : {},
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1.5 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
title={part ? "Change part" : "Choose part"}
|
||||||
|
aria-label={part ? "Change part" : "Choose part"}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{part?.url ? (
|
||||||
|
<a
|
||||||
|
href={part.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2.5 py-1.5 hover:bg-amber-300 transition-colors"
|
||||||
|
title="Buy part"
|
||||||
|
aria-label="Buy part"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-black" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 text-[11px] text-zinc-600">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-xs text-zinc-600">
|
||||||
|
Tip: If you landed here from a shared link, it uses <code>?build=</code> first; otherwise it reads your local build.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+64
-134
@@ -6,9 +6,17 @@ import { useSearchParams, useRouter } from "next/navigation";
|
|||||||
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 "@/data/partRoleMappings";
|
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
|
||||||
import { Pencil, Link2, Square, CheckSquare } from "lucide-react";
|
import { Pencil, Link2, Square, CheckSquare } from "lucide-react";
|
||||||
|
|
||||||
|
// ✅ Centralized overlap rules + helpers
|
||||||
|
import OverlapChip from "@/components/builder/OverlapChip";
|
||||||
|
import {
|
||||||
|
getOverlapChipsForCategory,
|
||||||
|
getSelectionHints,
|
||||||
|
type BuildState,
|
||||||
|
} from "@/lib/buildOverlaps";
|
||||||
|
|
||||||
type GunbuilderProductFromApi = {
|
type GunbuilderProductFromApi = {
|
||||||
id: number; // backend numeric id
|
id: number; // backend numeric id
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,8 +33,6 @@ const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
|||||||
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";
|
||||||
|
|
||||||
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
|
||||||
|
|
||||||
const STORAGE_KEY = "gunbuilder-build-state";
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
// Categories that should NOT be filtered by platform (universal accessories / gear)
|
// Categories that should NOT be filtered by platform (universal accessories / gear)
|
||||||
@@ -103,33 +109,6 @@ const CATEGORY_GROUPS: {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== Build rules: complete assemblies make sub-parts unnecessary =====
|
|
||||||
const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
|
|
||||||
const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
|
|
||||||
|
|
||||||
// If a complete upper is selected, these categories are considered "included".
|
|
||||||
const UPPER_INCLUDED_CATEGORIES = new Set<CategoryId>([
|
|
||||||
"upper-receiver",
|
|
||||||
"bcg",
|
|
||||||
"barrel",
|
|
||||||
"gas-block",
|
|
||||||
"gas-tube",
|
|
||||||
"muzzle-device",
|
|
||||||
"suppressor",
|
|
||||||
"handguard",
|
|
||||||
"charging-handle",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const LOWER_INCLUDED_CATEGORIES = new Set<CategoryId>([
|
|
||||||
"lower-receiver",
|
|
||||||
"lower-parts",
|
|
||||||
"trigger",
|
|
||||||
"grip",
|
|
||||||
"safety",
|
|
||||||
"buffer",
|
|
||||||
"stock",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default function GunbuilderPage() {
|
export default function GunbuilderPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -305,97 +284,25 @@ export default function GunbuilderPage() {
|
|||||||
[selectedParts]
|
[selectedParts]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------- Selection logic ----------
|
|
||||||
const handleSelectPart = useCallback(
|
const handleSelectPart = useCallback(
|
||||||
(
|
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
|
||||||
categoryId: CategoryId,
|
// NOTE:
|
||||||
partId: string,
|
// - We DO NOT auto-remove any existing selections.
|
||||||
opts?: { confirm?: boolean }
|
// - We show subtle overlap guidance (chips + an optional “heads up” toast).
|
||||||
) => {
|
|
||||||
const shouldConfirm = opts?.confirm !== false;
|
|
||||||
|
|
||||||
if (shouldConfirm && typeof window !== "undefined") {
|
const warn = (msg: string) => {
|
||||||
if (categoryId === COMPLETE_UPPER_CATEGORY) {
|
setShareStatus(msg);
|
||||||
const toClear = Array.from(UPPER_INCLUDED_CATEGORIES).filter(
|
window.setTimeout(() => setShareStatus(null), 4000);
|
||||||
(cid) => !!build[cid]
|
|
||||||
);
|
|
||||||
if (toClear.length > 0) {
|
|
||||||
const labels = toClear
|
|
||||||
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
const ok = window.confirm(
|
|
||||||
`Selecting a Complete Upper will remove your currently selected upper parts:\n\n${labels}\n\nContinue?`
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categoryId === COMPLETE_LOWER_CATEGORY) {
|
|
||||||
const toClear = Array.from(LOWER_INCLUDED_CATEGORIES).filter(
|
|
||||||
(cid) => !!build[cid]
|
|
||||||
);
|
|
||||||
if (toClear.length > 0) {
|
|
||||||
const labels = toClear
|
|
||||||
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
const ok = window.confirm(
|
|
||||||
`Selecting a Complete Lower will remove your currently selected lower parts:\n\n${labels}\n\nContinue?`
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
UPPER_INCLUDED_CATEGORIES.has(categoryId) &&
|
|
||||||
!!build[COMPLETE_UPPER_CATEGORY]
|
|
||||||
) {
|
|
||||||
const ok = window.confirm(
|
|
||||||
`Selecting this part will remove your selected Complete Upper. Continue?`
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
LOWER_INCLUDED_CATEGORIES.has(categoryId) &&
|
|
||||||
!!build[COMPLETE_LOWER_CATEGORY]
|
|
||||||
) {
|
|
||||||
const ok = window.confirm(
|
|
||||||
`Selecting this part will remove your selected Complete Lower. Continue?`
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setBuild((prev) => {
|
|
||||||
const updated: BuildState = {
|
|
||||||
...prev,
|
|
||||||
[categoryId]: partId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (categoryId === COMPLETE_UPPER_CATEGORY) {
|
const hints = getSelectionHints(categoryId, build);
|
||||||
for (const cid of UPPER_INCLUDED_CATEGORIES) {
|
if (hints.length > 0) warn(hints[0]);
|
||||||
delete updated[cid];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (UPPER_INCLUDED_CATEGORIES.has(categoryId)) {
|
// ✅ Only set the selection. No deletes. No clearing.
|
||||||
delete updated[COMPLETE_UPPER_CATEGORY];
|
setBuild((prev) => ({
|
||||||
}
|
...prev,
|
||||||
|
[categoryId]: partId,
|
||||||
if (categoryId === COMPLETE_LOWER_CATEGORY) {
|
}));
|
||||||
for (const cid of LOWER_INCLUDED_CATEGORIES) {
|
|
||||||
delete updated[cid];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (LOWER_INCLUDED_CATEGORIES.has(categoryId)) {
|
|
||||||
delete updated[COMPLETE_LOWER_CATEGORY];
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[build]
|
[build]
|
||||||
);
|
);
|
||||||
@@ -536,22 +443,18 @@ export default function GunbuilderPage() {
|
|||||||
const removeParam = searchParams.get("remove");
|
const removeParam = searchParams.get("remove");
|
||||||
const qpPlatform = searchParams.get("platform");
|
const qpPlatform = searchParams.get("platform");
|
||||||
|
|
||||||
// Build a unique key for this action so we only apply it once.
|
|
||||||
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${
|
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${
|
||||||
qpPlatform ?? ""
|
qpPlatform ?? ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
// If no action, clear the ref and do nothing.
|
|
||||||
if (!selectParam && !removeParam) {
|
if (!selectParam && !removeParam) {
|
||||||
processedActionKeyRef.current = "";
|
processedActionKeyRef.current = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent infinite loops: if we already processed this exact actionKey, stop.
|
|
||||||
if (processedActionKeyRef.current === actionKey) return;
|
if (processedActionKeyRef.current === actionKey) return;
|
||||||
processedActionKeyRef.current = actionKey;
|
processedActionKeyRef.current = actionKey;
|
||||||
|
|
||||||
// Apply actions
|
|
||||||
if (selectParam) {
|
if (selectParam) {
|
||||||
const [categoryIdRaw, partId] = selectParam.split(":");
|
const [categoryIdRaw, partId] = selectParam.split(":");
|
||||||
const requestedCategoryId = categoryIdRaw as CategoryId;
|
const requestedCategoryId = categoryIdRaw as CategoryId;
|
||||||
@@ -573,7 +476,6 @@ export default function GunbuilderPage() {
|
|||||||
|
|
||||||
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||||
|
|
||||||
// Clean URL (remove select/remove) but ONLY if needed.
|
|
||||||
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}`;
|
||||||
@@ -613,7 +515,6 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
}, [build]);
|
}, [build]);
|
||||||
|
|
||||||
// Use group ordering for the summary as well
|
|
||||||
const summaryCategoryOrder: CategoryId[] = useMemo(
|
const summaryCategoryOrder: CategoryId[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
|
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
|
||||||
@@ -812,7 +713,9 @@ export default function GunbuilderPage() {
|
|||||||
}}
|
}}
|
||||||
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 ${
|
||||||
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
|
selectedParts.length === 0
|
||||||
|
? "opacity-40 cursor-not-allowed"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Clear Build
|
Clear Build
|
||||||
@@ -828,7 +731,8 @@ export default function GunbuilderPage() {
|
|||||||
Share Your Build
|
Share Your Build
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[0.7rem] text-zinc-500">
|
<p className="mt-1 text-[0.7rem] text-zinc-500">
|
||||||
Share this link to let others view your build or bookmark it to come back later.
|
Share this link to let others view your build or bookmark it
|
||||||
|
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">
|
||||||
@@ -903,8 +807,9 @@ export default function GunbuilderPage() {
|
|||||||
{/* Layout */}
|
{/* Layout */}
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||||
<p className="mb-4 text-xs text-zinc-500">
|
<p className="mb-4 text-xs text-zinc-500">
|
||||||
Work top-down through the major sections. Each row shows your current pick for that part type,
|
Work top-down through the major sections. Each row shows your current
|
||||||
with price and a direct buy link—or a quick way to choose a part if you haven't picked one yet.
|
pick for that part type, with price and a direct buy link—or a quick
|
||||||
|
way to choose a part if you haven't picked one yet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -912,7 +817,8 @@ export default function GunbuilderPage() {
|
|||||||
)}
|
)}
|
||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<p className="text-sm text-red-400">
|
<p className="text-sm text-red-400">
|
||||||
{error} — check that the Ballistic API is running and CORS is configured.
|
{error} — check that the Ballistic API is running and CORS is
|
||||||
|
configured.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -967,12 +873,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;
|
||||||
@@ -987,10 +895,28 @@ export default function GunbuilderPage() {
|
|||||||
<div className="font-medium text-zinc-100">
|
<div className="font-medium text-zinc-100">
|
||||||
{category.name}
|
{category.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedPart ? (
|
{selectedPart ? (
|
||||||
|
<>
|
||||||
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
||||||
{selectedPart.name}
|
{selectedPart.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ Category overlap chips */}
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{getOverlapChipsForCategory(
|
||||||
|
category.id as CategoryId,
|
||||||
|
build
|
||||||
|
).map((c) => (
|
||||||
|
<OverlapChip
|
||||||
|
key={c.key}
|
||||||
|
tone={c.tone}
|
||||||
|
label={c.label}
|
||||||
|
detail={c.detail}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : hasParts ? (
|
) : hasParts ? (
|
||||||
<div className="text-[0.7rem] text-zinc-600">
|
<div className="text-[0.7rem] text-zinc-600">
|
||||||
No part selected
|
No part selected
|
||||||
@@ -1031,8 +957,10 @@ export default function GunbuilderPage() {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{
|
||||||
pathname: `/parts/${category.id}`,
|
pathname: `/parts/p/${platform}/${category.id}`,
|
||||||
query: platform ? { platform } : {},
|
query: platform
|
||||||
|
? { platform }
|
||||||
|
: {},
|
||||||
}}
|
}}
|
||||||
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
aria-label="Change part"
|
aria-label="Change part"
|
||||||
@@ -1055,8 +983,10 @@ export default function GunbuilderPage() {
|
|||||||
) : hasParts ? (
|
) : hasParts ? (
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{
|
||||||
pathname: `/parts/${category.id}`,
|
pathname: `/parts/p/${platform}/${category.id}`,
|
||||||
query: platform ? { platform } : {},
|
query: platform
|
||||||
|
? { platform }
|
||||||
|
: {},
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useStat, useEffect } 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 { CATEGORIES } from "@/data/gunbuilderParts";
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
|
import { buildDetailHref } from "@/app/parts/_components/buildDetailHref";
|
||||||
|
|
||||||
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||||
const STORAGE_KEY = "gunbuilder-build-state";
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
@@ -23,7 +24,7 @@ type GunbuilderProductFromApi = {
|
|||||||
buyUrl: string | null;
|
buyUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map backend partRole -> CategoryId (same as /gunbuilder page)
|
// Map backend partRole -> CategoryId (align with builder categories)
|
||||||
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
|
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
|
||||||
"upper-receiver": "upper",
|
"upper-receiver": "upper",
|
||||||
barrel: "barrel",
|
barrel: "barrel",
|
||||||
@@ -34,6 +35,12 @@ const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
|
|||||||
sight: "sights",
|
sight: "sights",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inverse: CategoryId -> backend partRole (for building detail URLs)
|
||||||
|
const CATEGORY_TO_PART_ROLE: Partial<Record<CategoryId, string>> =
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(PART_ROLE_TO_CATEGORY).map(([role, cat]) => [cat, role]),
|
||||||
|
) as Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
// Encode build state to URL-friendly string
|
// Encode build state to URL-friendly string
|
||||||
function encodeBuildState(build: BuildState): string {
|
function encodeBuildState(build: BuildState): string {
|
||||||
const entries = Object.entries(build)
|
const entries = Object.entries(build)
|
||||||
@@ -64,6 +71,9 @@ export default function BuildDetailsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ✅ Option A: platform is a query param and is passed through to backend
|
||||||
|
const platform = searchParams.get("platform") ?? "AR-15";
|
||||||
|
|
||||||
const [build, setBuild] = useState<BuildState>({});
|
const [build, setBuild] = useState<BuildState>({});
|
||||||
const [shareUrl, setShareUrl] = useState<string>("");
|
const [shareUrl, setShareUrl] = useState<string>("");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -78,23 +88,25 @@ export default function BuildDetailsPage() {
|
|||||||
if (buildParam) {
|
if (buildParam) {
|
||||||
const decoded = decodeBuildState(buildParam);
|
const decoded = decodeBuildState(buildParam);
|
||||||
setBuild(decoded);
|
setBuild(decoded);
|
||||||
// Also save to localStorage
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
|
||||||
|
} catch {
|
||||||
|
// ignore storage failures (private mode, etc.)
|
||||||
|
}
|
||||||
} else if (typeof window !== "undefined") {
|
} else if (typeof window !== "undefined") {
|
||||||
// Load from localStorage
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
setBuild(parsed);
|
setBuild(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid stored data
|
// ignore invalid stored data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Fetch live parts from backend (same source as /gunbuilder)
|
// Fetch live parts from backend (uses selected platform)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -104,7 +116,7 @@ export default function BuildDetailsPage() {
|
|||||||
setPartsError(null);
|
setPartsError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
|
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}`,
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,7 +132,7 @@ export default function BuildDetailsPage() {
|
|||||||
if (!categoryId) return null;
|
if (!categoryId) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: p.id, // already a string UUID
|
id: p.id,
|
||||||
categoryId,
|
categoryId,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand,
|
brand: p.brand,
|
||||||
@@ -134,26 +146,29 @@ export default function BuildDetailsPage() {
|
|||||||
|
|
||||||
setParts(normalized);
|
setParts(normalized);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
setPartsError(err.message ?? "Failed to load products");
|
setPartsError(err?.message ?? "Failed to load products");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingParts(false);
|
setLoadingParts(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, [platform]);
|
||||||
|
|
||||||
// Generate shareable URL
|
// Generate shareable URL (includes platform)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
|
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
|
||||||
const encoded = encodeBuildState(build);
|
const encoded = encodeBuildState(build);
|
||||||
const url = `${window.location.origin}/builder/build?build=${encoded}`;
|
const url = `${window.location.origin}/builder/build?platform=${encodeURIComponent(
|
||||||
|
platform,
|
||||||
|
)}&build=${encoded}`;
|
||||||
setShareUrl(url);
|
setShareUrl(url);
|
||||||
|
} else if (typeof window !== "undefined") {
|
||||||
|
setShareUrl("");
|
||||||
}
|
}
|
||||||
}, [build]);
|
}, [build, platform]);
|
||||||
|
|
||||||
const selectedParts: Part[] = useMemo(() => {
|
const selectedParts: Part[] = useMemo(() => {
|
||||||
if (parts.length === 0) return [];
|
if (parts.length === 0) return [];
|
||||||
@@ -173,7 +188,7 @@ export default function BuildDetailsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
if (shareUrl) {
|
if (!shareUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
@@ -181,23 +196,25 @@ export default function BuildDetailsPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy:", err);
|
console.error("Failed to copy:", err);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (navigator.share && shareUrl) {
|
if (!shareUrl) return;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: "My AR-15 Build",
|
title: `My ${platform} Build`,
|
||||||
text: `Check out my AR-15 build: $${totalPrice.toFixed(2)}`,
|
text: `Check out my ${platform} build: $${totalPrice.toFixed(2)}`,
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
handleCopyLink();
|
// fall back to copy
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
handleCopyLink();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await handleCopyLink();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!loadingParts && selectedParts.length === 0) {
|
if (!loadingParts && selectedParts.length === 0) {
|
||||||
@@ -212,7 +229,7 @@ export default function BuildDetailsPage() {
|
|||||||
You need to select at least one part to view your build.
|
You need to select at least one part to view your build.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href={`/builder?platform=${encodeURIComponent(platform)}`}
|
||||||
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
>
|
>
|
||||||
Go to Builder
|
Go to Builder
|
||||||
@@ -229,11 +246,12 @@ export default function BuildDetailsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href={`/builder?platform=${encodeURIComponent(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 Armory
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<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">
|
||||||
@@ -246,6 +264,9 @@ export default function BuildDetailsPage() {
|
|||||||
Review your build, purchase parts from our affiliate partners, or
|
Review your build, purchase parts from our affiliate partners, or
|
||||||
share your build with others.
|
share your build with others.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-zinc-500">
|
||||||
|
Platform: <span className="text-zinc-200">{platform}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 md:mt-0">
|
<div className="mt-3 md:mt-0">
|
||||||
@@ -293,6 +314,7 @@ export default function BuildDetailsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareUrl && (
|
{shareUrl && (
|
||||||
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
|
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
|
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
|
||||||
@@ -327,6 +349,10 @@ export default function BuildDetailsPage() {
|
|||||||
p.categoryId === (category.id as CategoryId),
|
p.categoryId === (category.id as CategoryId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const partRole =
|
||||||
|
(CATEGORY_TO_PART_ROLE[category.id] as string | undefined) ??
|
||||||
|
"unknown";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -337,12 +363,14 @@ export default function BuildDetailsPage() {
|
|||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
|
||||||
{category.name}
|
{category.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{part ? (
|
{part ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-lg font-semibold text-zinc-50 mb-1">
|
<div className="text-lg font-semibold text-zinc-50 mb-1">
|
||||||
<span className="text-zinc-400">{part.brand}</span>{" "}
|
<span className="text-zinc-400">{part.brand}</span>{" "}
|
||||||
{part.name}
|
{part.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2 flex-wrap">
|
<div className="mt-3 flex gap-2 flex-wrap">
|
||||||
{part.url && (
|
{part.url && (
|
||||||
<a
|
<a
|
||||||
@@ -367,8 +395,14 @@ export default function BuildDetailsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${category.id}/${part.id}`}
|
href={buildDetailHref({
|
||||||
|
id: part.id,
|
||||||
|
name: part.name,
|
||||||
|
platform,
|
||||||
|
partRole,
|
||||||
|
} as any)}
|
||||||
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
>
|
>
|
||||||
View Details
|
View Details
|
||||||
@@ -376,11 +410,10 @@ export default function BuildDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-zinc-600">
|
<div className="text-sm text-zinc-600">Not selected</div>
|
||||||
Not selected
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{part && (
|
{part && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-semibold text-amber-300">
|
<div className="text-2xl font-semibold text-amber-300">
|
||||||
@@ -398,17 +431,22 @@ export default function BuildDetailsPage() {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href={`/builder?platform=${encodeURIComponent(platform)}`}
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
|
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
|
||||||
>
|
>
|
||||||
Edit Build
|
Edit Build
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
try {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
router.push("/builder");
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
router.push(`/builder?platform=${encodeURIComponent(platform)}`);
|
||||||
}}
|
}}
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { BuilderNav } from "@/components/BuilderNav";
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
|
import { TopNav } from "@/components/TopNav";
|
||||||
|
|
||||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-950 text-zinc-50">
|
<div className="min-h-screen bg-neutral-950 text-zinc-50">
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<TopNav />
|
||||||
<BuilderNav />
|
<BuilderNav />
|
||||||
<main className="min-h-screen">{children}</main>
|
<main className="min-h-screen">{children}</main>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -1,802 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
|
||||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
|
||||||
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
|
|
||||||
|
|
||||||
type ViewMode = "card" | "list";
|
|
||||||
|
|
||||||
type GunbuilderProductFromApi = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
price: number | null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
buyUrl: string | null;
|
|
||||||
inStock?: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UiPart = Part & {
|
|
||||||
inStock?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"];
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
type BuildState = Partial<Record<CategoryId, string>>;
|
|
||||||
|
|
||||||
const STORAGE_KEY = "gunbuilder-build-state";
|
|
||||||
|
|
||||||
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
|
||||||
|
|
||||||
const slugify = (str: string) =>
|
|
||||||
str
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/(^-|-$)/g, "");
|
|
||||||
|
|
||||||
const normalizeRole = (role: string) =>
|
|
||||||
role.trim().toLowerCase().replace(/_/g, "-");
|
|
||||||
|
|
||||||
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
|
|
||||||
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
|
|
||||||
| string[]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!roles || roles.length === 0) return [];
|
|
||||||
return Array.from(new Set(roles.map(normalizeRole)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CategoryPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const categoryId = params.category as CategoryId;
|
|
||||||
|
|
||||||
const partRoles = useMemo(
|
|
||||||
() => getNormalizedPartRolesForCategory(String(categoryId)),
|
|
||||||
[categoryId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
|
||||||
const [parts, setParts] = useState<UiPart[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
|
||||||
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
|
||||||
const [priceRange, setPriceRange] = useState<{
|
|
||||||
min: number | null;
|
|
||||||
max: number | null;
|
|
||||||
}>({ min: null, max: null });
|
|
||||||
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
||||||
const PAGE_SIZE = 24;
|
|
||||||
|
|
||||||
const [build] = useState<BuildState>(() => {
|
|
||||||
if (typeof window === "undefined") return {};
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
return stored ? (JSON.parse(stored) as BuildState) : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
|
|
||||||
const [platform, setPlatform] = useState<string>(initialPlatform);
|
|
||||||
|
|
||||||
const category = useMemo(
|
|
||||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
|
||||||
[categoryId]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!categoryId) return;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function fetchCategoryParts() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// If no role mapping exists, fail loudly (helps debugging)
|
|
||||||
if (partRoles.length === 0) {
|
|
||||||
setParts([]);
|
|
||||||
setError(
|
|
||||||
`No partRole mapping found for category "${categoryId}". Check CATEGORY_TO_PART_ROLES.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = new URLSearchParams();
|
|
||||||
search.set("platform", platform);
|
|
||||||
|
|
||||||
for (const r of partRoles) {
|
|
||||||
search.append("partRoles", r);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/api/products?${search.toString()}`;
|
|
||||||
|
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to load products (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: GunbuilderProductFromApi[] = await res.json();
|
|
||||||
|
|
||||||
const normalized: UiPart[] = data.map((p) => ({
|
|
||||||
id: String(p.id),
|
|
||||||
categoryId,
|
|
||||||
name: p.name,
|
|
||||||
brand: p.brand,
|
|
||||||
price: p.price ?? 0,
|
|
||||||
imageUrl: p.imageUrl ?? undefined,
|
|
||||||
url: p.buyUrl ?? undefined,
|
|
||||||
affiliateUrl: p.buyUrl ?? undefined,
|
|
||||||
notes: undefined,
|
|
||||||
inStock: p.inStock ?? true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setParts(normalized);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.name === "AbortError") return;
|
|
||||||
setError(err.message ?? "Failed to load products");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log("[parts] categoryId", categoryId, "partRoles", partRoles);
|
|
||||||
fetchCategoryParts();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [categoryId, platform, partRoles]);
|
|
||||||
|
|
||||||
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
|
|
||||||
const isSelected = build[categoryId] === partId;
|
|
||||||
|
|
||||||
const qp = new URLSearchParams();
|
|
||||||
qp.set("platform", platform || "AR-15");
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
qp.set("remove", String(categoryId));
|
|
||||||
router.push(`/builder?${qp.toString()}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qp.set("select", `${categoryId}:${partId}`);
|
|
||||||
router.push(`/builder?${qp.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableBrands = useMemo(
|
|
||||||
() =>
|
|
||||||
Array.from(new Set(parts.map((p) => p.brand)))
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((a, b) => a.localeCompare(b)),
|
|
||||||
[parts]
|
|
||||||
);
|
|
||||||
|
|
||||||
const priceBounds = useMemo(() => {
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return { min: null as number | null, max: null as number | null };
|
|
||||||
}
|
|
||||||
let min = Number.POSITIVE_INFINITY;
|
|
||||||
let max = Number.NEGATIVE_INFINITY;
|
|
||||||
|
|
||||||
for (const p of parts) {
|
|
||||||
const price = p.price ?? 0;
|
|
||||||
if (price < min) min = price;
|
|
||||||
if (price > max) max = price;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
||||||
return { min: null as number | null, max: null as number | null };
|
|
||||||
}
|
|
||||||
return { min, max };
|
|
||||||
}, [parts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (priceBounds.min == null || priceBounds.max == null) return;
|
|
||||||
|
|
||||||
setPriceRange((prev) => {
|
|
||||||
const min = prev.min ?? priceBounds.min!;
|
|
||||||
const max = prev.max ?? priceBounds.max!;
|
|
||||||
return {
|
|
||||||
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
|
|
||||||
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [priceBounds.min, priceBounds.max]);
|
|
||||||
|
|
||||||
const filteredParts = useMemo(() => {
|
|
||||||
let result = [...parts];
|
|
||||||
|
|
||||||
const effectiveMin =
|
|
||||||
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
|
|
||||||
const effectiveMax =
|
|
||||||
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
|
|
||||||
|
|
||||||
if (effectiveMin != null && effectiveMax != null) {
|
|
||||||
result = result.filter((p) => {
|
|
||||||
const price = p.price ?? 0;
|
|
||||||
return price >= effectiveMin && price <= effectiveMax;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (brandFilter.length > 0) {
|
|
||||||
result = result.filter((p) => brandFilter.includes(p.brand));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inStockOnly) {
|
|
||||||
result = result.filter((p) => p.inStock ?? true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
||||||
if (normalizedQuery) {
|
|
||||||
result = result.filter((p) => {
|
|
||||||
const name = p.name.toLowerCase();
|
|
||||||
const brand = p.brand.toLowerCase();
|
|
||||||
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (sortBy) {
|
|
||||||
case "price-asc":
|
|
||||||
result.sort((a, b) => a.price - b.price);
|
|
||||||
break;
|
|
||||||
case "price-desc":
|
|
||||||
result.sort((a, b) => b.price - a.price);
|
|
||||||
break;
|
|
||||||
case "brand-asc":
|
|
||||||
result.sort((a, b) => a.brand.localeCompare(b.brand));
|
|
||||||
break;
|
|
||||||
case "relevance":
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedId = build[categoryId];
|
|
||||||
if (selectedId) {
|
|
||||||
const selected = result.filter((p) => p.id === selectedId);
|
|
||||||
const others = result.filter((p) => p.id !== selectedId);
|
|
||||||
result = [...selected, ...others];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceRange, inStockOnly, priceBounds.min, priceBounds.max]);
|
|
||||||
|
|
||||||
const totalPages = useMemo(
|
|
||||||
() =>
|
|
||||||
filteredParts.length === 0
|
|
||||||
? 1
|
|
||||||
: Math.ceil(filteredParts.length / PAGE_SIZE),
|
|
||||||
[filteredParts, PAGE_SIZE]
|
|
||||||
);
|
|
||||||
|
|
||||||
const paginatedParts = useMemo(() => {
|
|
||||||
if (filteredParts.length === 0) return [];
|
|
||||||
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
|
||||||
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
|
||||||
}, [filteredParts, currentPage, PAGE_SIZE]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
|
|
||||||
setPlatform(nextPlatform);
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
const visibleRange = useMemo(() => {
|
|
||||||
if (filteredParts.length === 0) return { start: 0, end: 0 };
|
|
||||||
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
|
||||||
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
|
|
||||||
return { start, end };
|
|
||||||
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
|
||||||
Category Not Found
|
|
||||||
</h1>
|
|
||||||
<Link
|
|
||||||
href={{ pathname: "/builder", query: { platform } }}
|
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
|
||||||
>
|
|
||||||
← Back to The Armory
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
|
||||||
<header className="mb-6">
|
|
||||||
<Link
|
|
||||||
href={{ pathname: "/builder", query: { platform } }}
|
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
|
||||||
>
|
|
||||||
← Back to The Armory
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Battl Builder
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
|
||||||
{category.name} <span className="text-amber-300">Parts</span>
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
|
||||||
Browse all available {category.name.toLowerCase()} options for
|
|
||||||
your build, pulled live from your Ballistic backend.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!loading && !error && parts.length > 0 && (
|
|
||||||
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode("card")}
|
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
|
||||||
viewMode === "card"
|
|
||||||
? "bg-zinc-800 text-zinc-50"
|
|
||||||
: "text-zinc-400 hover:text-zinc-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Card
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode("list")}
|
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
|
||||||
viewMode === "list"
|
|
||||||
? "bg-zinc-800 text-zinc-50"
|
|
||||||
: "text-zinc-400 hover:text-zinc-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
{!loading && !error && parts.length > 0 && (
|
|
||||||
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
|
||||||
Filters
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-[11px] text-zinc-500">
|
|
||||||
Narrow down the {category.name.toLowerCase()} list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
|
||||||
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
|
||||||
Price
|
|
||||||
</span>
|
|
||||||
{(priceRange.min !== null || priceRange.max !== null) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setPriceRange({
|
|
||||||
min: priceBounds.min,
|
|
||||||
max: priceBounds.max,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{priceBounds.min == null || priceBounds.max == null ? (
|
|
||||||
<p className="text-[11px] text-zinc-500">
|
|
||||||
No price data available.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-[10px] text-zinc-400">
|
|
||||||
<span>
|
|
||||||
Min:{" "}
|
|
||||||
<span className="font-semibold text-zinc-200">
|
|
||||||
${(priceRange.min ?? priceBounds.min).toFixed(0)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Max:{" "}
|
|
||||||
<span className="font-semibold text-zinc-200">
|
|
||||||
${(priceRange.max ?? priceBounds.max).toFixed(0)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={priceBounds.min ?? 0}
|
|
||||||
max={priceBounds.max ?? 0}
|
|
||||||
value={priceRange.min ?? priceBounds.min ?? 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = Number(e.target.value);
|
|
||||||
setPriceRange((prev) => ({
|
|
||||||
min: Math.min(
|
|
||||||
value,
|
|
||||||
prev.max ?? priceBounds.max ?? value
|
|
||||||
),
|
|
||||||
max: prev.max ?? priceBounds.max ?? value,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={priceBounds.min ?? 0}
|
|
||||||
max={priceBounds.max ?? 0}
|
|
||||||
value={priceRange.max ?? priceBounds.max ?? 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = Number(e.target.value);
|
|
||||||
setPriceRange((prev) => ({
|
|
||||||
min: prev.min ?? priceBounds.min ?? value,
|
|
||||||
max: Math.max(
|
|
||||||
value,
|
|
||||||
prev.min ?? priceBounds.min ?? value
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
|
||||||
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
|
||||||
Brand
|
|
||||||
</span>
|
|
||||||
{brandFilter.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBrandFilter([])}
|
|
||||||
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{availableBrands.length === 0 ? (
|
|
||||||
<p className="text-[11px] text-zinc-500">
|
|
||||||
No brands available yet.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
|
|
||||||
{availableBrands.map((b) => {
|
|
||||||
const checked = brandFilter.includes(b);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={b}
|
|
||||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => {
|
|
||||||
setBrandFilter((prev) =>
|
|
||||||
e.target.checked
|
|
||||||
? [...prev, b]
|
|
||||||
: prev.filter((brand) => brand !== b)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{b}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{brandFilter.length === 0 && availableBrands.length > 0 && (
|
|
||||||
<p className="mt-1 text-[10px] text-zinc-500">
|
|
||||||
No brands selected — showing all.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-zinc-800 pt-3">
|
|
||||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
|
|
||||||
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
|
|
||||||
In stock only
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
|
||||||
checked={inStockOnly}
|
|
||||||
onChange={(e) => setInStockOnly(e.target.checked)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p className="mt-1 text-[10px] text-zinc-500">
|
|
||||||
Hides items marked out of stock by the backend.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
{!loading && !error && parts.length > 0 && (
|
|
||||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="text-xs text-zinc-500">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="font-medium text-zinc-200">
|
|
||||||
{visibleRange.start}-{visibleRange.end}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="font-medium text-zinc-200">
|
|
||||||
{filteredParts.length}
|
|
||||||
</span>{" "}
|
|
||||||
matching parts
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label
|
|
||||||
htmlFor="part-search"
|
|
||||||
className="text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="part-search"
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder={`Search ${category.name.toLowerCase()}...`}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label
|
|
||||||
htmlFor="platform-select"
|
|
||||||
className="text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
Platform
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="platform-select"
|
|
||||||
value={platform}
|
|
||||||
onChange={(e) => setPlatform(e.target.value)}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
>
|
|
||||||
{PLATFORMS.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label htmlFor="sort-by" className="text-xs text-zinc-500">
|
|
||||||
Sort
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="sort-by"
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
>
|
|
||||||
<option value="relevance">Relevance</option>
|
|
||||||
<option value="price-asc">Price: Low → High</option>
|
|
||||||
<option value="price-desc">Price: High → Low</option>
|
|
||||||
<option value="brand-asc">Brand: A → Z</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="py-8 text-center text-sm text-zinc-500">
|
|
||||||
Loading {category.name.toLowerCase()}…
|
|
||||||
</p>
|
|
||||||
) : error ? (
|
|
||||||
<p className="py-8 text-center text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : filteredParts.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-sm text-zinc-500">
|
|
||||||
No parts available for this category yet.
|
|
||||||
</p>
|
|
||||||
) : viewMode === "card" ? (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{paginatedParts.map((part) => {
|
|
||||||
const isSelected = build[categoryId] === part.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={part.id}
|
|
||||||
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
|
|
||||||
isSelected
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
|
||||||
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-3 flex items-center gap-2 justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-semibold text-zinc-50">
|
|
||||||
{part.brand}{" "}
|
|
||||||
<span className="font-normal">— {part.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
|
|
||||||
${part.price.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Link
|
|
||||||
href={{
|
|
||||||
pathname: `/parts/${categoryId}/${part.id}-${slugify(
|
|
||||||
part.name
|
|
||||||
)}`,
|
|
||||||
query: { platform },
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTogglePart(categoryId, part.id)}
|
|
||||||
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
|
||||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
|
|
||||||
<span>Part</span>
|
|
||||||
<span>Brand</span>
|
|
||||||
<span className="text-right">Price</span>
|
|
||||||
<span className="pr-2 text-right">Actions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{paginatedParts.map((part) => {
|
|
||||||
const isSelected = build[categoryId] === part.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={part.id}
|
|
||||||
className={`group relative rounded-md border p-3 transition-all duration-200 ${
|
|
||||||
isSelected
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
|
||||||
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-semibold text-zinc-50">
|
|
||||||
{part.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
|
|
||||||
{part.brand}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-amber-300 md:text-right">
|
|
||||||
${part.price.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Link
|
|
||||||
href={{
|
|
||||||
pathname: `/parts/${categoryId}/${part.id}-${slugify(
|
|
||||||
part.name
|
|
||||||
)}`,
|
|
||||||
query: { platform },
|
|
||||||
}}
|
|
||||||
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTogglePart(categoryId, part.id)}
|
|
||||||
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
|
||||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredParts.length > 0 && totalPages > 1 && (
|
|
||||||
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
|
|
||||||
<div>
|
|
||||||
Page{" "}
|
|
||||||
<span className="font-semibold text-zinc-200">
|
|
||||||
{currentPage}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="font-semibold text-zinc-200">
|
|
||||||
{totalPages}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage((p) => Math.min(totalPages, p + 1))
|
|
||||||
}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
import {
|
||||||
|
PART_ROLE_TO_CATEGORY,
|
||||||
|
normalizePartRole,
|
||||||
|
} from "@/lib/catalogMappings";
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
function extractNumericId(productSlug: string): number | null {
|
||||||
|
if (!productSlug) return null;
|
||||||
|
const first = productSlug.split("-")[0];
|
||||||
|
const n = Number(first);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number | null | undefined): string {
|
||||||
|
if (price == null) return "—";
|
||||||
|
return `$${price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// NEW ROUTE PARAMS:
|
||||||
|
const platformParam = String(params.platform ?? "");
|
||||||
|
const partRoleParam = String(params.partRole ?? "");
|
||||||
|
const productSlug = String(params.productSlug ?? "");
|
||||||
|
|
||||||
|
// Platform comes from path segment (fallback to ?platform just in case)
|
||||||
|
const platform =
|
||||||
|
platformParam ||
|
||||||
|
(searchParams.get("platform") as string) ||
|
||||||
|
"AR-15";
|
||||||
|
|
||||||
|
const normalizedRole = useMemo(
|
||||||
|
() => normalizePartRole(partRoleParam),
|
||||||
|
[partRoleParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryId = useMemo(() => {
|
||||||
|
return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
|
||||||
|
}, [partRoleParam, normalizedRole]);
|
||||||
|
|
||||||
|
const numericId = useMemo(
|
||||||
|
() => extractNumericId(productSlug),
|
||||||
|
[productSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read-only build state (builder page owns writes)
|
||||||
|
const [build] = useState<BuildState>(() => {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||||
|
const isSelected =
|
||||||
|
!!categoryId &&
|
||||||
|
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// If platform segment is missing/empty, normalize into the new route (not /builder)
|
||||||
|
useEffect(() => {
|
||||||
|
if (platformParam) return;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||||
|
|
||||||
|
// Fetch product
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numericId) {
|
||||||
|
setError("Invalid product id.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchProduct() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/products/${numericId}`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load product (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GunbuilderProductFromApi = await res.json();
|
||||||
|
setProduct(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load product");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProduct();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [numericId]);
|
||||||
|
|
||||||
|
const handleTogglePart = () => {
|
||||||
|
if (!numericId) return;
|
||||||
|
|
||||||
|
// If mapping is missing, don’t send a bogus builder action
|
||||||
|
if (!categoryId) {
|
||||||
|
alert(
|
||||||
|
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
qp.set("remove", String(categoryId));
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qp.set("select", `${categoryId}:${numericId}`);
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||||
|
Unknown Part Role
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-400 mb-6">
|
||||||
|
No mapping found for <span className="text-zinc-200">{partRoleParam}</span>.
|
||||||
|
Add it to <span className="text-zinc-200">catalogMappings</span>.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Back to Parts List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<header className="mb-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Link
|
||||||
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
The Armory
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
Parts
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<span className="text-zinc-300">Details</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
BATTL BUILDERS
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Product <span className="text-amber-300">Details</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
|
Live product data pulled from your Ballistic backend. Add it to your build
|
||||||
|
or jump back to compare options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform switch (updates NEW route) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="platform-select" className="text-xs text-zinc-500">
|
||||||
|
Platform
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="platform-select"
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
>
|
||||||
|
{PLATFORMS.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">Loading product…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-10 text-center text-sm text-red-400">
|
||||||
|
{error} — check that the Ballistic API is running.
|
||||||
|
</p>
|
||||||
|
) : !product ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">Product not found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||||
|
{/* Left: image */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||||
|
{product.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="h-full w-full object-contain p-2"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Stock
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold ${
|
||||||
|
product.inStock === false ? "text-red-300" : "text-emerald-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.inStock === false ? "Out of stock" : "In stock"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Back to List
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePart}
|
||||||
|
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: details */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
{product.brand}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
|
||||||
|
{product.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Platform:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.platform || platform}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Role:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.partRole || partRoleParam}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
ID:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Current price
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||||
|
{formatPrice(product.price)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePart}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{product.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={product.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-center text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Buy from Merchant →
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-4 py-2 text-center text-sm text-zinc-500">
|
||||||
|
No buy link available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Notes
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
This details page is intentionally “thin” for now — once we wire in
|
||||||
|
offers, price history, and richer product fields, this section becomes
|
||||||
|
the spec + comparison hub.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Tip: use “Back to List” to compare multiple parts quickly.
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Platform comes from the URL segment:{" "}
|
||||||
|
<span className="text-zinc-300">/parts/p/{platform}/...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function PartsRoleRedirectPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: { partRole: string };
|
||||||
|
searchParams?: { platform?: string };
|
||||||
|
}) {
|
||||||
|
const partRole = params.partRole;
|
||||||
|
const platform = searchParams?.platform ?? "AR-15";
|
||||||
|
|
||||||
|
redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// app/(builder)/parts/_components/buildDetailHref.ts
|
||||||
|
|
||||||
|
const toSlug = (str: string) =>
|
||||||
|
str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
|
export type BuildDetailInput = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDetailHref(
|
||||||
|
platform: string,
|
||||||
|
partRole: string,
|
||||||
|
p: BuildDetailInput
|
||||||
|
) {
|
||||||
|
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
|
||||||
|
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRole
|
||||||
|
)}/${p.id}-${slug}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||||
|
|
||||||
|
type OfferFromApi = {
|
||||||
|
merchantName?: string | null;
|
||||||
|
price?: number | null;
|
||||||
|
buyUrl?: string | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
lastUpdated?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
|
||||||
|
// image fields can vary depending on endpoint/version
|
||||||
|
imageUrl?: string | null;
|
||||||
|
mainImageUrl?: string | null;
|
||||||
|
|
||||||
|
// single best-link (legacy)
|
||||||
|
buyUrl?: string | null;
|
||||||
|
|
||||||
|
// stock info
|
||||||
|
inStock?: boolean | null;
|
||||||
|
|
||||||
|
// richer details (optional)
|
||||||
|
shortDescription?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
// offers (optional)
|
||||||
|
offers?: OfferFromApi[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
function extractNumericId(productSlug: string): number | null {
|
||||||
|
if (!productSlug) return null;
|
||||||
|
const first = productSlug.split("-")[0];
|
||||||
|
const n = Number(first);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number | null | undefined): string {
|
||||||
|
if (price == null) return "—";
|
||||||
|
return `$${price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
||||||
|
return [...offers].sort((a, b) => {
|
||||||
|
const ap = a.price ?? Number.POSITIVE_INFINITY;
|
||||||
|
const bp = b.price ?? Number.POSITIVE_INFINITY;
|
||||||
|
if (ap !== bp) return ap - bp;
|
||||||
|
|
||||||
|
// in-stock first
|
||||||
|
const aStock = a.inStock === false ? 1 : 0;
|
||||||
|
const bStock = b.inStock === false ? 1 : 0;
|
||||||
|
if (aStock !== bStock) return aStock - bStock;
|
||||||
|
|
||||||
|
// merchant name
|
||||||
|
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Route params
|
||||||
|
const platformParam = String(params.platform ?? "");
|
||||||
|
const partRoleParam = String(params.partRole ?? "");
|
||||||
|
const productSlug = String(params.productSlug ?? "");
|
||||||
|
|
||||||
|
// Platform comes from path segment (fallback to ?platform just in case)
|
||||||
|
const platform =
|
||||||
|
platformParam || (searchParams.get("platform") as string) || "AR-15";
|
||||||
|
|
||||||
|
const normalizedRole = useMemo(
|
||||||
|
() => normalizePartRole(partRoleParam),
|
||||||
|
[partRoleParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryId = useMemo(() => {
|
||||||
|
return (
|
||||||
|
PART_ROLE_TO_CATEGORY[partRoleParam] ??
|
||||||
|
PART_ROLE_TO_CATEGORY[normalizedRole] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [partRoleParam, normalizedRole]);
|
||||||
|
|
||||||
|
const numericId = useMemo(() => extractNumericId(productSlug), [productSlug]);
|
||||||
|
|
||||||
|
// Read-only build state (builder page owns writes)
|
||||||
|
const [build, setBuild] = useState<BuildState>(() => {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep build in sync if user changes build elsewhere
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key !== STORAGE_KEY) return;
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||||
|
const isSelected =
|
||||||
|
!!categoryId && selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// If platform segment is missing/empty, normalize into the new route
|
||||||
|
useEffect(() => {
|
||||||
|
if (platformParam) return;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||||
|
|
||||||
|
// Fetch product
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numericId) {
|
||||||
|
setError("Invalid product id.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchProduct() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/products/${numericId}`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load product (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GunbuilderProductFromApi = await res.json();
|
||||||
|
setProduct(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load product");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProduct();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [numericId]);
|
||||||
|
|
||||||
|
const handleTogglePart = () => {
|
||||||
|
if (!numericId) return;
|
||||||
|
|
||||||
|
// If mapping is missing, don’t send a bogus builder action
|
||||||
|
if (!categoryId) {
|
||||||
|
alert(
|
||||||
|
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
qp.set("remove", String(categoryId));
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qp.set("select", `${categoryId}:${numericId}`);
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
|
||||||
|
|
||||||
|
// Offers normalization:
|
||||||
|
// - If product.offers exists, use it.
|
||||||
|
// - Otherwise, fall back to a single “offer” from product.buyUrl/product.price.
|
||||||
|
const offers = useMemo(() => {
|
||||||
|
if (!product) return [];
|
||||||
|
|
||||||
|
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[];
|
||||||
|
if (raw.length > 0) return sortOffers(raw);
|
||||||
|
|
||||||
|
if (product.buyUrl) {
|
||||||
|
return sortOffers([
|
||||||
|
{
|
||||||
|
merchantName: "Merchant",
|
||||||
|
price: product.price,
|
||||||
|
buyUrl: product.buyUrl,
|
||||||
|
inStock: product.inStock ?? true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||||
|
Unknown Part Role
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-400 mb-6">
|
||||||
|
No mapping found for{" "}
|
||||||
|
<span className="text-zinc-200">{partRoleParam}</span>. Add it to{" "}
|
||||||
|
<span className="text-zinc-200">catalogMappings</span>.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Back to Parts List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<header className="mb-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Link
|
||||||
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
The Armory
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
Parts
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<span className="text-zinc-300">Details</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
BATTL BUILDERS
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Product <span className="text-amber-300">Details</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
|
Offers, pricing placeholders, and builder actions — all on the canonical
|
||||||
|
/parts/p route.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform switch (updates NEW route) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="platform-select" className="text-xs text-zinc-500">
|
||||||
|
Platform
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="platform-select"
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
>
|
||||||
|
{PLATFORMS.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">
|
||||||
|
Loading product…
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-10 text-center text-sm text-red-400">
|
||||||
|
{error} — check that the Ballistic API is running.
|
||||||
|
</p>
|
||||||
|
) : !product ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">
|
||||||
|
Product not found.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||||
|
{/* Left: image + core actions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||||
|
{imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="h-full w-full object-contain p-2"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Stock
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold ${
|
||||||
|
product.inStock === false
|
||||||
|
? "text-red-300"
|
||||||
|
: "text-emerald-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.inStock === false ? "Out of stock" : "In stock"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Back to List
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePart}
|
||||||
|
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? "Remove" : "Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing history placeholder */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Price History
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] text-zinc-600">placeholder</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
|
||||||
|
Pricing graph will go here
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-500">
|
||||||
|
We’ll wire this to price snapshots once the offer model is stable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: details + offers */}
|
||||||
|
<div className="min-w-0 space-y-4">
|
||||||
|
{/* Product meta */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
{product.brand}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
|
||||||
|
{product.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Platform:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.platform || platform}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Role:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.partRole || partRoleParam}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
ID:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Current price
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||||
|
{formatPrice(product.price)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(product.shortDescription || product.description) && (
|
||||||
|
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||||
|
{product.shortDescription ? (
|
||||||
|
<p className="text-sm text-zinc-300">
|
||||||
|
{product.shortDescription}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{product.description ? (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm text-zinc-400">
|
||||||
|
{product.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Offers */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Merchant Offers
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] text-zinc-600">
|
||||||
|
{offers.length ? `${offers.length} offers` : "no offers"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{offers.length ? (
|
||||||
|
<div className="mt-3 overflow-x-auto rounded border border-zinc-800">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="bg-zinc-900/60">
|
||||||
|
<tr className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
<th className="px-3 py-2 text-left">Merchant</th>
|
||||||
|
<th className="px-3 py-2 text-left">Stock</th>
|
||||||
|
<th className="px-3 py-2 text-right">Price</th>
|
||||||
|
<th className="px-3 py-2 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{offers.map((o, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-zinc-900/40">
|
||||||
|
<td className="px-3 py-2 text-zinc-200">
|
||||||
|
{o.merchantName ?? "Merchant"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{o.inStock === false ? (
|
||||||
|
<span className="text-red-300">Out of stock</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-emerald-300">In stock</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
||||||
|
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{o.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={o.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-zinc-500">
|
||||||
|
No offers attached yet. Once offers are available, this becomes the
|
||||||
|
“where to buy” table.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keep a single "best" buy CTA if buyUrl exists */}
|
||||||
|
{product.buyUrl ? (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<a
|
||||||
|
href={product.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Buy from Merchant →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer tips */}
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Tip: Add this part, then go back and compare alternates.
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Canonical route:{" "}
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
/parts/p/{platform}/{partRoleParam}/{productSlug}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||||
|
|
||||||
|
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
|
||||||
|
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
||||||
|
}
|
||||||
+15
-13
@@ -1,16 +1,18 @@
|
|||||||
export const metadata = {
|
// app/(builder)/layout.tsx
|
||||||
title: 'Next.js',
|
import type { ReactNode } from "react";
|
||||||
description: 'Generated by Next.js',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
children,
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
}: {
|
import { TopNav } from "@/components/TopNav";
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<div className="min-h-screen bg-neutral-950 text-zinc-50">
|
||||||
<body>{children}</body>
|
<AuthProvider>
|
||||||
</html>
|
<TopNav />
|
||||||
)
|
<BuilderNav />
|
||||||
|
<main className="min-h-screen">{children}</main>
|
||||||
|
</AuthProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PriceHistoryPoint } from "@/app/(builder)/parts/[category]/[partId]/data";
|
import type { PriceHistoryPoint } from "@/app/(builder)/parts/[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/[category]/[partId]/data";
|
import type { RetailerOffer } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data";
|
||||||
|
|
||||||
interface RetailersListProps {
|
interface RetailersListProps {
|
||||||
retailers: RetailerOffer[];
|
retailers: RetailerOffer[];
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// /components/builder/OverlapChip.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function OverlapChip(props: {
|
||||||
|
tone?: "warning" | "info";
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
}) {
|
||||||
|
const { tone = "info", label, detail } = props;
|
||||||
|
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setTimeout(() => setReady(true), 10);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const base =
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] leading-4 transition-all duration-200";
|
||||||
|
const pop = ready ? "opacity-100 scale-100" : "opacity-0 scale-95";
|
||||||
|
|
||||||
|
const toneCls =
|
||||||
|
tone === "warning"
|
||||||
|
? "border-amber-400/40 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-200";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${base} ${toneCls} ${pop}`} title={detail ?? label}>
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
{detail ? <span className="text-zinc-400">· {detail}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function Filters(props: {
|
||||||
|
partRoleLabel: string;
|
||||||
|
|
||||||
|
availableBrands: string[];
|
||||||
|
brandFilter: string[];
|
||||||
|
setBrandFilter: (v: string[]) => void;
|
||||||
|
|
||||||
|
priceBounds: { min: number | null; max: number | null };
|
||||||
|
priceRange: { min: number | null; max: number | null };
|
||||||
|
setPriceRange: (v: { min: number | null; max: number | null }) => void;
|
||||||
|
|
||||||
|
inStockOnly: boolean;
|
||||||
|
setInStockOnly: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
availableBrands,
|
||||||
|
brandFilter,
|
||||||
|
setBrandFilter,
|
||||||
|
priceBounds,
|
||||||
|
priceRange,
|
||||||
|
setPriceRange,
|
||||||
|
inStockOnly,
|
||||||
|
setInStockOnly,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-full rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Filters
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[11px] text-zinc-500">
|
||||||
|
Narrow down the results.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price range */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||||
|
Price
|
||||||
|
</span>
|
||||||
|
{(priceRange.min !== null || priceRange.max !== null) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setPriceRange({
|
||||||
|
min: priceBounds.min,
|
||||||
|
max: priceBounds.max,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{priceBounds.min == null || priceBounds.max == null ? (
|
||||||
|
<p className="text-[11px] text-zinc-500">No price data available.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-zinc-400">
|
||||||
|
<span>
|
||||||
|
Min:{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
${(priceRange.min ?? priceBounds.min).toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Max:{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
${(priceRange.max ?? priceBounds.max).toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={priceBounds.min ?? 0}
|
||||||
|
max={priceBounds.max ?? 0}
|
||||||
|
value={priceRange.min ?? priceBounds.min ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
setPriceRange({
|
||||||
|
min: Math.min(value, priceRange.max ?? priceBounds.max ?? value),
|
||||||
|
max: priceRange.max ?? priceBounds.max ?? value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={priceBounds.min ?? 0}
|
||||||
|
max={priceBounds.max ?? 0}
|
||||||
|
value={priceRange.max ?? priceBounds.max ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
setPriceRange({
|
||||||
|
min: priceRange.min ?? priceBounds.min ?? value,
|
||||||
|
max: Math.max(value, priceRange.min ?? priceBounds.min ?? value),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand multi-select */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||||
|
Brand
|
||||||
|
</span>
|
||||||
|
{brandFilter.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrandFilter([])}
|
||||||
|
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableBrands.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-zinc-500">No brands available yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
|
||||||
|
{availableBrands.map((b) => {
|
||||||
|
const checked = brandFilter.includes(b);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={b}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBrandFilter(
|
||||||
|
e.target.checked
|
||||||
|
? [...brandFilter, b]
|
||||||
|
: brandFilter.filter((x) => x !== b)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{b}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brandFilter.length === 0 && availableBrands.length > 0 && (
|
||||||
|
<p className="mt-1 text-[10px] text-zinc-500">
|
||||||
|
No brands selected — showing all.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In-stock toggle */}
|
||||||
|
<div className="border-t border-zinc-800 pt-3">
|
||||||
|
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
|
||||||
|
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||||
|
In stock only
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||||
|
checked={inStockOnly}
|
||||||
|
onChange={(e) => setInStockOnly(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-[10px] text-zinc-500">
|
||||||
|
Hides items marked out of stock by the backend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
|
export default function OverlapChip(props: {
|
||||||
|
label: string;
|
||||||
|
tooltip?: string;
|
||||||
|
/** stable key so chip doesn't re-animate on trivial rerenders */
|
||||||
|
persistKey?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { label, tooltip, persistKey, className } = props;
|
||||||
|
|
||||||
|
// Animate only once per persistKey per session
|
||||||
|
const storageKey = persistKey ? `bb_overlapchip_seen:${persistKey}` : null;
|
||||||
|
|
||||||
|
const [animateIn, setAnimateIn] = useState(false);
|
||||||
|
const tooltipId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageKey) {
|
||||||
|
// No key provided: animate once on mount
|
||||||
|
setAnimateIn(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seen = sessionStorage.getItem(storageKey);
|
||||||
|
if (!seen) {
|
||||||
|
sessionStorage.setItem(storageKey, "1");
|
||||||
|
setAnimateIn(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage blocked — still animate
|
||||||
|
setAnimateIn(true);
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center ${className ?? ""}`}>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border border-amber-400/50 bg-amber-400/10 px-2 py-1",
|
||||||
|
"text-[0.7rem] font-medium text-amber-200",
|
||||||
|
"transition-all duration-300 ease-out",
|
||||||
|
animateIn ? "opacity-100 translate-y-0" : "opacity-0 translate-y-1",
|
||||||
|
].join(" ")}
|
||||||
|
aria-describedby={tooltip ? tooltipId : undefined}
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-amber-300">
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Optional: if you later want a real tooltip instead of title,
|
||||||
|
you can wire one here using tooltipId. */}
|
||||||
|
{tooltip ? <span id={tooltipId} className="sr-only">{tooltip}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function Pagination(props: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
const { currentPage, totalPages, onPrev, onNext } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
|
||||||
|
<div>
|
||||||
|
Page{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">{totalPages}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PartsBrowseClient
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
* Browse shell for parts listing pages.
|
||||||
|
* Owns:
|
||||||
|
* - fetching parts from backend
|
||||||
|
* - filter + sort + pagination state
|
||||||
|
* - layout + list/card views
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import Filters from "@/components/parts/Filters";
|
||||||
|
import SortBar from "@/components/parts/SortBar";
|
||||||
|
import PartsGrid from "@/components/parts/PartsGrid";
|
||||||
|
import Pagination from "@/components/parts/Pagination";
|
||||||
|
import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
|
||||||
|
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||||
|
|
||||||
|
type ViewMode = "card" | "list";
|
||||||
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
mainImageUrl: string | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UiPart = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number; // normalized
|
||||||
|
imageUrl?: string;
|
||||||
|
buyUrl?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
function normalizeId(id: string | number) {
|
||||||
|
return typeof id === "number" ? String(id) : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(s: string) {
|
||||||
|
return (s ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical product details route:
|
||||||
|
* /parts/p/[platform]/[partRole]/[productSlug]
|
||||||
|
*/
|
||||||
|
function buildDetailHref(platform: string, partRole: string, p: UiPart) {
|
||||||
|
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
|
||||||
|
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${encodeURIComponent(
|
||||||
|
`${p.id}-${slug}`
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PartsBrowseClient(props: {
|
||||||
|
partRole: string;
|
||||||
|
platform?: string | null; // if null, read from query param or default
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const effectivePlatform = props.platform ?? searchParams.get("platform") ?? "AR-15";
|
||||||
|
const partRole = props.partRole;
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
|
const [parts, setParts] = useState<UiPart[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
||||||
|
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
});
|
||||||
|
const [inStockOnly, setInStockOnly] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!partRole) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchParts() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
search.set("platform", effectivePlatform);
|
||||||
|
search.append("partRoles", partRole);
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/products?${search.toString()}`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
||||||
|
|
||||||
|
const data: GunbuilderProductFromApi[] = await res.json();
|
||||||
|
|
||||||
|
const normalized: UiPart[] = data.map((p) => ({
|
||||||
|
id: normalizeId(p.id),
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand,
|
||||||
|
platform: p.platform,
|
||||||
|
partRole: p.partRole,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
imageUrl: p.mainImageUrl ?? undefined,
|
||||||
|
buyUrl: p.buyUrl ?? undefined,
|
||||||
|
inStock: p.inStock ?? true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setParts(normalized);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load products");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchParts();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [partRole, effectivePlatform]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [partRole, effectivePlatform, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
|
||||||
|
|
||||||
|
const availableBrands = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(new Set(parts.map((p) => p.brand)))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
|
[parts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const priceBounds = useMemo(() => {
|
||||||
|
if (parts.length === 0) return { min: null as number | null, max: null as number | null };
|
||||||
|
|
||||||
|
let min = Number.POSITIVE_INFINITY;
|
||||||
|
let max = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const p of parts) {
|
||||||
|
const price = p.price ?? 0;
|
||||||
|
if (price < min) min = price;
|
||||||
|
if (price > max) max = price;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||||
|
return { min: null as number | null, max: null as number | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { min, max };
|
||||||
|
}, [parts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (priceBounds.min == null || priceBounds.max == null) return;
|
||||||
|
|
||||||
|
setPriceRange((prev) => {
|
||||||
|
const min = prev.min ?? priceBounds.min!;
|
||||||
|
const max = prev.max ?? priceBounds.max!;
|
||||||
|
return {
|
||||||
|
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
|
||||||
|
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [priceBounds.min, priceBounds.max]);
|
||||||
|
|
||||||
|
const filteredParts = useMemo(() => {
|
||||||
|
let result = [...parts];
|
||||||
|
|
||||||
|
const effectiveMin = priceRange.min ?? priceBounds.min;
|
||||||
|
const effectiveMax = priceRange.max ?? priceBounds.max;
|
||||||
|
if (effectiveMin != null && effectiveMax != null) {
|
||||||
|
result = result.filter((p) => p.price >= effectiveMin && p.price <= effectiveMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brandFilter.length > 0) {
|
||||||
|
result = result.filter((p) => brandFilter.includes(p.brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inStockOnly) {
|
||||||
|
result = result.filter((p) => p.inStock ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
result = result.filter((p) => {
|
||||||
|
const name = (p.name ?? "").toLowerCase();
|
||||||
|
const brand = (p.brand ?? "").toLowerCase();
|
||||||
|
return name.includes(q) || brand.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case "price-asc":
|
||||||
|
result.sort((a, b) => a.price - b.price);
|
||||||
|
break;
|
||||||
|
case "price-desc":
|
||||||
|
result.sort((a, b) => b.price - a.price);
|
||||||
|
break;
|
||||||
|
case "brand-asc":
|
||||||
|
result.sort((a, b) => a.brand.localeCompare(b.brand));
|
||||||
|
break;
|
||||||
|
case "relevance":
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [parts, brandFilter, sortBy, searchQuery, priceRange, priceBounds.min, priceBounds.max, inStockOnly]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)),
|
||||||
|
[filteredParts.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const paginatedParts = useMemo(() => {
|
||||||
|
if (filteredParts.length === 0) return [];
|
||||||
|
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
||||||
|
}, [filteredParts, currentPage]);
|
||||||
|
|
||||||
|
const visibleRange = useMemo(() => {
|
||||||
|
if (filteredParts.length === 0) return { start: 0, end: 0 };
|
||||||
|
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
||||||
|
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
|
||||||
|
return { start, end };
|
||||||
|
}, [filteredParts.length, currentPage, paginatedParts.length]);
|
||||||
|
|
||||||
|
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
|
||||||
|
const headingSubtitle = props.subtitle ?? "Browse available parts pulled from your Ballistic backend.";
|
||||||
|
|
||||||
|
// ✅ Add-to-build handler: deep-links into builder’s existing ?select= logic
|
||||||
|
const handleAddToBuild = (p: UiPart) => {
|
||||||
|
const normalizedRole = normalizePartRole(partRole);
|
||||||
|
const categoryId: CategoryId | null =
|
||||||
|
PART_ROLE_TO_CATEGORY[partRole] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
alert(`No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", effectivePlatform);
|
||||||
|
qp.set("select", `${categoryId}:${p.id}`);
|
||||||
|
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
<header className="mb-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
{headingTitle} <span className="text-amber-300">{effectivePlatform}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-xl">{headingSubtitle}</p>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<PlatformSwitcher currentPlatform={effectivePlatform} partRole={partRole} preserveQuery />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && parts.length > 0 && (
|
||||||
|
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("card")}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||||
|
viewMode === "card" ? "bg-zinc-800 text-zinc-50" : "text-zinc-400 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Card
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||||
|
viewMode === "list" ? "bg-zinc-800 text-zinc-50" : "text-zinc-400 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
{!loading && !error && parts.length > 0 && (
|
||||||
|
<aside className="w-full md:w-64 shrink-0">
|
||||||
|
<Filters
|
||||||
|
partRoleLabel={partRole}
|
||||||
|
availableBrands={availableBrands}
|
||||||
|
brandFilter={brandFilter}
|
||||||
|
setBrandFilter={setBrandFilter}
|
||||||
|
priceBounds={priceBounds}
|
||||||
|
priceRange={priceRange}
|
||||||
|
setPriceRange={setPriceRange}
|
||||||
|
inStockOnly={inStockOnly}
|
||||||
|
setInStockOnly={setInStockOnly}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
{!loading && !error && parts.length > 0 && (
|
||||||
|
<SortBar
|
||||||
|
visibleRange={visibleRange}
|
||||||
|
totalCount={filteredParts.length}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
setSortBy={setSortBy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500">Loading…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-8 text-center text-sm text-red-400">
|
||||||
|
{error} — check that the Ballistic API is running.
|
||||||
|
</p>
|
||||||
|
) : filteredParts.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500">No parts found for this role yet.</p>
|
||||||
|
) : (
|
||||||
|
<PartsGrid
|
||||||
|
viewMode={viewMode}
|
||||||
|
parts={paginatedParts}
|
||||||
|
buildDetailHref={(p) => buildDetailHref(effectivePlatform, partRole, p)}
|
||||||
|
onAddToBuild={handleAddToBuild}
|
||||||
|
addLabel="Add to Build"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && filteredParts.length > 0 && totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPrev={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
onNext={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type UiPart = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
buyUrl?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PartsGrid(props: {
|
||||||
|
viewMode: "card" | "list";
|
||||||
|
parts: UiPart[];
|
||||||
|
buildDetailHref: (p: UiPart) => string;
|
||||||
|
|
||||||
|
// NEW: optional Add-to-Build behavior
|
||||||
|
onAddToBuild?: (p: UiPart) => void;
|
||||||
|
addLabel?: string;
|
||||||
|
}) {
|
||||||
|
const { viewMode, parts, buildDetailHref, onAddToBuild } = props;
|
||||||
|
const addLabel = props.addLabel ?? "Add to Build";
|
||||||
|
|
||||||
|
if (viewMode === "card") {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className="group relative flex flex-col rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2 justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-zinc-50 truncate">
|
||||||
|
{part.brand} <span className="font-normal">— {part.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
|
||||||
|
${part.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={buildDetailHref(part)}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* NEW: Add to Build (preferred primary action if provided) */}
|
||||||
|
{onAddToBuild ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddToBuild(part)}
|
||||||
|
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
) : part.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={part.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="flex-1 rounded-md bg-zinc-700 px-3 py-2 text-center text-xs font-semibold text-zinc-200 opacity-50"
|
||||||
|
>
|
||||||
|
No Action
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIST view
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
|
||||||
|
<span>Part</span>
|
||||||
|
<span>Brand</span>
|
||||||
|
<span className="text-right">Price</span>
|
||||||
|
<span className="pr-2 text-right">Actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-zinc-50 truncate">
|
||||||
|
{part.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
|
||||||
|
{part.brand}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm font-semibold text-amber-300 md:text-right">
|
||||||
|
${part.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={buildDetailHref(part)}
|
||||||
|
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{onAddToBuild ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddToBuild(part)}
|
||||||
|
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
) : part.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={part.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="whitespace-nowrap rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 opacity-50"
|
||||||
|
>
|
||||||
|
No Action
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PlatformSwitcher from "./PlatformSwitcher";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type ProductListDto = {
|
||||||
|
id: string; // your API returns strings sometimes; keep it flexible
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categoryKey: string | null;
|
||||||
|
price: number | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeSlugify(input: string) {
|
||||||
|
return (input ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PartsListPageClient(props: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
}) {
|
||||||
|
const { platform, partRole } = props;
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ProductListDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Simple client-side search (fast + good enough for now)
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Your current list endpoint:
|
||||||
|
// GET /api/products?platform=AR-15&partRoles=upper-receiver
|
||||||
|
const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Failed to load products (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ProductListDto[] = await res.json();
|
||||||
|
if (!cancelled) setItems(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message ?? "Failed to load products");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [platform, partRole]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
if (!needle) return items;
|
||||||
|
|
||||||
|
return items.filter((p) => {
|
||||||
|
const blob = `${p.brand ?? ""} ${p.name ?? ""} ${p.categoryKey ?? ""}`.toLowerCase();
|
||||||
|
return blob.includes(needle);
|
||||||
|
});
|
||||||
|
}, [items, q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
<header className="mb-6 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Parts: <span className="text-amber-300">{partRole}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlatformSwitcher currentPlatform={platform} partRole={partRole} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search brand, name, category…"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-zinc-500">Loading parts…</p>}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
Results
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
{filtered.length} items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{filtered.map((p) => {
|
||||||
|
const id = String(p.id);
|
||||||
|
const slug = safeSlugify(p.name);
|
||||||
|
const href = `/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${id}-${slug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={`${p.id}`}
|
||||||
|
href={href}
|
||||||
|
className="group rounded-lg border border-zinc-800 bg-black/30 p-3 hover:border-zinc-700"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="h-16 w-16 overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{p.imageUrl ? (
|
||||||
|
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full grid place-items-center text-xs text-zinc-600">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
{p.brand}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-zinc-100 group-hover:text-amber-200">
|
||||||
|
{p.name}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-zinc-500 line-clamp-1">
|
||||||
|
{p.categoryKey ?? "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-zinc-200">
|
||||||
|
{typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlatformSwitcher
|
||||||
|
*
|
||||||
|
* Works on BOTH:
|
||||||
|
* - /parts/[partRole] (default browse route)
|
||||||
|
* - /parts/[platform]/[partRole] (canonical route)
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - If you're on /parts/[partRole], switching platform navigates to /parts/[platform]/[partRole]
|
||||||
|
* (so your URL becomes explicit/shareable).
|
||||||
|
* - If you're already on /parts/[platform]/[partRole], it swaps the platform segment.
|
||||||
|
*/
|
||||||
|
export default function PlatformSwitcher(props: {
|
||||||
|
currentPlatform: string;
|
||||||
|
partRole: string;
|
||||||
|
// If true, we keep query params when switching (nice for sort/paging)
|
||||||
|
preserveQuery?: boolean;
|
||||||
|
}) {
|
||||||
|
const { currentPlatform, partRole, preserveQuery = true } = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const queryString = useMemo(() => {
|
||||||
|
if (!preserveQuery) return "";
|
||||||
|
const q = searchParams?.toString();
|
||||||
|
return q ? `?${q}` : "";
|
||||||
|
}, [searchParams, preserveQuery]);
|
||||||
|
|
||||||
|
function go(platform: string) {
|
||||||
|
// We always navigate to canonical to keep it simple & consistent
|
||||||
|
router.push(`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}${queryString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs uppercase tracking-[0.14em] text-zinc-500">Platform</span>
|
||||||
|
<select
|
||||||
|
value={currentPlatform}
|
||||||
|
onChange={(e) => go(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
>
|
||||||
|
<option value="AR-15">AR-15</option>
|
||||||
|
<option value="AR-10">AR-10</option>
|
||||||
|
<option value="AR-9">AR-9</option>
|
||||||
|
<option value="AK-47">AK-47</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span className="ml-2 text-[0.7rem] text-zinc-500">
|
||||||
|
{pathname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PlatformSwitcher from "./PlatformSwitcher";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type ProductDetailDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categoryKey: string | null;
|
||||||
|
price: number | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseIdFromProductSlug(productSlug: string): string | null {
|
||||||
|
// Expected: "1217-some-slug"
|
||||||
|
const m = /^(\d+)(?:-|$)/.exec(productSlug ?? "");
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to fetch a product detail.
|
||||||
|
* If you don't have a dedicated endpoint yet, it falls back to list + filter by id.
|
||||||
|
*/
|
||||||
|
async function fetchProductDetail(params: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
productSlug: string;
|
||||||
|
}): Promise<ProductDetailDto> {
|
||||||
|
const { platform, partRole, productSlug } = params;
|
||||||
|
const id = parseIdFromProductSlug(productSlug);
|
||||||
|
|
||||||
|
if (!id) throw new Error(`Invalid product URL slug: '${productSlug}' (could not parse id)`);
|
||||||
|
|
||||||
|
// ---- Preferred (if you add it): GET /api/products/{id}
|
||||||
|
// If it 404s, we fall back.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/products/${encodeURIComponent(id)}`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall back
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fallback: use list endpoint + find by id (works right now with your current API)
|
||||||
|
const listRes = await fetch(
|
||||||
|
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
|
||||||
|
{ headers: { Accept: "application/json" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!listRes.ok) {
|
||||||
|
const txt = await listRes.text().catch(() => "");
|
||||||
|
throw new Error(`Failed to load product (fallback) (${listRes.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list: ProductDetailDto[] = await listRes.json();
|
||||||
|
const found = list.find((p) => String(p.id) === String(id));
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`Product id=${id} not found in ${platform}/${partRole} list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailPageClient(props: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
productSlug: string;
|
||||||
|
}) {
|
||||||
|
const { platform, partRole, productSlug } = props;
|
||||||
|
|
||||||
|
const [p, setP] = useState<ProductDetailDto | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const id = useMemo(() => parseIdFromProductSlug(productSlug), [productSlug]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const data = await fetchProductDetail({ platform, partRole, productSlug });
|
||||||
|
|
||||||
|
if (!cancelled) setP(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message ?? "Failed to load product");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [platform, partRole, productSlug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
|
||||||
|
<header className="mb-6 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Part: <span className="text-amber-300">{partRole}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlatformSwitcher currentPlatform={platform} partRole={partRole} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Link className="hover:text-amber-200" href={`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}>
|
||||||
|
← Back to list
|
||||||
|
</Link>
|
||||||
|
<span>•</span>
|
||||||
|
<span>id: {id ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-zinc-500">Loading product…</p>}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && p && (
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 md:p-6">
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row">
|
||||||
|
<div className="w-full md:w-72">
|
||||||
|
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{p.imageUrl ? (
|
||||||
|
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full grid place-items-center text-sm text-zinc-600">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
{p.brand} • {p.platform}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-xl md:text-2xl font-semibold text-zinc-100">
|
||||||
|
{p.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 text-sm">
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Category</p>
|
||||||
|
<p className="mt-1 text-zinc-200">{p.categoryKey ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Best price</p>
|
||||||
|
<p className="mt-1 text-zinc-200">
|
||||||
|
{typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
{p.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={p.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
Buy / View offer
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-zinc-500">No buy link</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
|
export default function SortBar(props: {
|
||||||
|
visibleRange: { start: number; end: number };
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (v: string) => void;
|
||||||
|
|
||||||
|
sortBy: SortOption;
|
||||||
|
setSortBy: (v: SortOption) => void;
|
||||||
|
}) {
|
||||||
|
const { visibleRange, totalCount, searchQuery, setSearchQuery, sortBy, setSortBy } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium text-zinc-200">
|
||||||
|
{visibleRange.start}-{visibleRange.end}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-medium text-zinc-200">
|
||||||
|
{totalCount}
|
||||||
|
</span>{" "}
|
||||||
|
matching parts
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="part-search" className="text-xs text-zinc-500">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="part-search"
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="sort-by" className="text-xs text-zinc-500">
|
||||||
|
Sort
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sort-by"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
>
|
||||||
|
<option value="relevance">Relevance</option>
|
||||||
|
<option value="price-asc">Price: Low → High</option>
|
||||||
|
<option value="price-desc">Price: High → Low</option>
|
||||||
|
<option value="brand-asc">Brand: A → Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// /lib/buildOverlaps.ts
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
|
||||||
|
export type OverlapChipModel = {
|
||||||
|
key: string;
|
||||||
|
tone: "warning" | "info";
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
|
||||||
|
export const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
|
||||||
|
|
||||||
|
export const UPPER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"upper-receiver",
|
||||||
|
"bcg",
|
||||||
|
"barrel",
|
||||||
|
"gas-block",
|
||||||
|
"gas-tube",
|
||||||
|
"muzzle-device",
|
||||||
|
"suppressor",
|
||||||
|
"handguard",
|
||||||
|
"charging-handle",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const LOWER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"lower-receiver",
|
||||||
|
"lower-parts",
|
||||||
|
"trigger",
|
||||||
|
"grip",
|
||||||
|
"safety",
|
||||||
|
"buffer",
|
||||||
|
"stock",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
function selectedCount(build: BuildState, set: Set<CategoryId>) {
|
||||||
|
let count = 0;
|
||||||
|
for (const cid of set) if (build[cid]) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedList(build: BuildState, set: Set<CategoryId>) {
|
||||||
|
const list: CategoryId[] = [];
|
||||||
|
for (const cid of set) if (build[cid]) list.push(cid);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chips to show on a given category row (education / “you may not need both”)
|
||||||
|
*/
|
||||||
|
export function getOverlapChipsForCategory(
|
||||||
|
categoryId: CategoryId,
|
||||||
|
build: BuildState
|
||||||
|
): OverlapChipModel[] {
|
||||||
|
const chips: OverlapChipModel[] = [];
|
||||||
|
|
||||||
|
const hasCompleteUpper = !!build[COMPLETE_UPPER_CATEGORY];
|
||||||
|
const hasCompleteLower = !!build[COMPLETE_LOWER_CATEGORY];
|
||||||
|
|
||||||
|
// ---- Upper overlap ----
|
||||||
|
if (categoryId === COMPLETE_UPPER_CATEGORY) {
|
||||||
|
const count = selectedCount(build, UPPER_OVERLAP_CATEGORIES);
|
||||||
|
if (count > 0) {
|
||||||
|
chips.push({
|
||||||
|
key: "upper-complete-overlaps-subparts",
|
||||||
|
tone: "warning",
|
||||||
|
label: "Overlaps selected upper parts",
|
||||||
|
detail: `${count} upper part${count === 1 ? "" : "s"} also selected`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UPPER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteUpper) {
|
||||||
|
chips.push({
|
||||||
|
key: "upper-subpart-in-complete",
|
||||||
|
tone: "info",
|
||||||
|
label: "Included in Complete Upper",
|
||||||
|
detail: "You have both selected (compare if you want)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lower overlap ----
|
||||||
|
if (categoryId === COMPLETE_LOWER_CATEGORY) {
|
||||||
|
const count = selectedCount(build, LOWER_OVERLAP_CATEGORIES);
|
||||||
|
if (count > 0) {
|
||||||
|
chips.push({
|
||||||
|
key: "lower-complete-overlaps-subparts",
|
||||||
|
tone: "warning",
|
||||||
|
label: "Overlaps selected lower parts",
|
||||||
|
detail: `${count} lower part${count === 1 ? "" : "s"} also selected`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOWER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteLower) {
|
||||||
|
chips.push({
|
||||||
|
key: "lower-subpart-in-complete",
|
||||||
|
tone: "info",
|
||||||
|
label: "Included in Complete Lower",
|
||||||
|
detail: "You have both selected (compare if you want)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-blocking “heads up” messages when selecting a part.
|
||||||
|
* (Use for toasts / shareStatus strip — no auto-removal)
|
||||||
|
*/
|
||||||
|
export function getSelectionHints(
|
||||||
|
nextCategoryId: CategoryId,
|
||||||
|
build: BuildState
|
||||||
|
): string[] {
|
||||||
|
const hints: string[] = [];
|
||||||
|
|
||||||
|
if (nextCategoryId === COMPLETE_UPPER_CATEGORY) {
|
||||||
|
const overlaps = selectedList(build, UPPER_OVERLAP_CATEGORIES);
|
||||||
|
if (overlaps.length > 0) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: Complete Upper overlaps with some selected upper parts. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UPPER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_UPPER_CATEGORY]) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: You also have a Complete Upper selected. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextCategoryId === COMPLETE_LOWER_CATEGORY) {
|
||||||
|
const overlaps = selectedList(build, LOWER_OVERLAP_CATEGORIES);
|
||||||
|
if (overlaps.length > 0) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: Complete Lower overlaps with some selected lower parts. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOWER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_LOWER_CATEGORY]) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: You also have a Complete Lower selected. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// app/lib/catalog.ts
|
||||||
|
export const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export type ProductListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categoryKey?: string | null;
|
||||||
|
price?: number | null;
|
||||||
|
buyUrl?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
slug?: string | null; // if your API returns it; otherwise we derive it client-side
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductDetail = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
rawCategoryKey?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
shortDescription?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
offers?: Array<{
|
||||||
|
merchantName?: string;
|
||||||
|
price?: number | null;
|
||||||
|
buyUrl?: string | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function slugify(input: string) {
|
||||||
|
return (input ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Your existing list endpoint:
|
||||||
|
* GET /api/products?platform=AR-15&partRoles=complete-upper
|
||||||
|
*/
|
||||||
|
export async function fetchProducts(params: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
}) {
|
||||||
|
const { platform, partRole } = params;
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`${API_BASE_URL}/api/products?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
platform,
|
||||||
|
partRoles: partRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
||||||
|
const data = (await res.json()) as ProductListItem[];
|
||||||
|
|
||||||
|
// Ensure each item has a "productSlug" of the form "{id}-{slugified-name}"
|
||||||
|
return data.map((p) => ({
|
||||||
|
...p,
|
||||||
|
productSlug: `${p.id}-${slugify(p.name)}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You likely have (or should add) a detail endpoint like:
|
||||||
|
* GET /api/products/{id}?platform=AR-15 OR GET /api/products/{id}
|
||||||
|
*
|
||||||
|
* We'll call /api/products/{id} and optionally include platform as a query param.
|
||||||
|
*/
|
||||||
|
export async function fetchProductById(params: {
|
||||||
|
id: string;
|
||||||
|
platform?: string;
|
||||||
|
}) {
|
||||||
|
const { id, platform } = params;
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`${API_BASE_URL}/api/products/${encodeURIComponent(id)}` +
|
||||||
|
(platform ? `?${new URLSearchParams({ platform })}` : "");
|
||||||
|
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
|
||||||
|
return (await res.json()) as ProductDetail;
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
|
|||||||
// ===== LOWER =====
|
// ===== LOWER =====
|
||||||
"lower-receiver": ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
"lower-receiver": ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
||||||
// (optional) Back-compat if anything still uses the old ids:
|
// (optional) Back-compat if anything still uses the old ids:
|
||||||
lower: ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
|
||||||
|
|
||||||
"complete-lower": ["complete-lower"],
|
"complete-lower": ["complete-lower"],
|
||||||
"lower-parts": ["lower-parts-kit", "lower-parts"],
|
"lower-parts": ["lower-parts-kit", "lower-parts"],
|
||||||
Reference in New Issue
Block a user