lots of fixes. cant remember them all

This commit is contained in:
2025-12-18 09:42:25 -05:00
parent 607939c468
commit 4c2d767d09
28 changed files with 3342 additions and 996 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
"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 {
data: PriceHistoryPoint[];
+1 -1
View File
@@ -1,6 +1,6 @@
"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 {
retailers: RetailerOffer[];
+34
View File
@@ -0,0 +1,34 @@
// /components/builder/OverlapChip.tsx
"use client";
import { useEffect, useState } from "react";
export default function OverlapChip(props: {
tone?: "warning" | "info";
label: string;
detail?: string;
}) {
const { tone = "info", label, detail } = props;
const [ready, setReady] = useState(false);
useEffect(() => {
const id = window.setTimeout(() => setReady(true), 10);
return () => window.clearTimeout(id);
}, []);
const base =
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] leading-4 transition-all duration-200";
const pop = ready ? "opacity-100 scale-100" : "opacity-0 scale-95";
const toneCls =
tone === "warning"
? "border-amber-400/40 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-200";
return (
<span className={`${base} ${toneCls} ${pop}`} title={detail ?? label}>
<span className="font-medium">{label}</span>
{detail ? <span className="text-zinc-400">· {detail}</span> : null}
</span>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import React from "react";
export default function Filters(props: {
partRoleLabel: string;
availableBrands: string[];
brandFilter: string[];
setBrandFilter: (v: string[]) => void;
priceBounds: { min: number | null; max: number | null };
priceRange: { min: number | null; max: number | null };
setPriceRange: (v: { min: number | null; max: number | null }) => void;
inStockOnly: boolean;
setInStockOnly: (v: boolean) => void;
}) {
const {
availableBrands,
brandFilter,
setBrandFilter,
priceBounds,
priceRange,
setPriceRange,
inStockOnly,
setInStockOnly,
} = props;
return (
<aside className="w-full rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the results.
</p>
</div>
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{(priceRange.min !== null || priceRange.max !== null) && (
<button
type="button"
onClick={() =>
setPriceRange({
min: priceBounds.min,
max: priceBounds.max,
})
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Reset
</button>
)}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">No price data available.</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange({
min: Math.min(value, priceRange.max ?? priceBounds.max ?? value),
max: priceRange.max ?? priceBounds.max ?? value,
});
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange({
min: priceRange.min ?? priceBounds.min ?? value,
max: Math.max(value, priceRange.min ?? priceBounds.min ?? value),
});
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">No brands available yet.</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter(
e.target.checked
? [...brandFilter, b]
: brandFilter.filter((x) => x !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</aside>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useId, useState } from "react";
export default function OverlapChip(props: {
label: string;
tooltip?: string;
/** stable key so chip doesn't re-animate on trivial rerenders */
persistKey?: string;
className?: string;
}) {
const { label, tooltip, persistKey, className } = props;
// Animate only once per persistKey per session
const storageKey = persistKey ? `bb_overlapchip_seen:${persistKey}` : null;
const [animateIn, setAnimateIn] = useState(false);
const tooltipId = useId();
useEffect(() => {
if (!storageKey) {
// No key provided: animate once on mount
setAnimateIn(true);
return;
}
try {
const seen = sessionStorage.getItem(storageKey);
if (!seen) {
sessionStorage.setItem(storageKey, "1");
setAnimateIn(true);
}
} catch {
// sessionStorage blocked — still animate
setAnimateIn(true);
}
}, [storageKey]);
return (
<span className={`inline-flex items-center ${className ?? ""}`}>
<span
className={[
"inline-flex items-center gap-1.5 rounded-full border border-amber-400/50 bg-amber-400/10 px-2 py-1",
"text-[0.7rem] font-medium text-amber-200",
"transition-all duration-300 ease-out",
animateIn ? "opacity-100 translate-y-0" : "opacity-0 translate-y-1",
].join(" ")}
aria-describedby={tooltip ? tooltipId : undefined}
title={tooltip}
>
<span aria-hidden="true" className="text-amber-300">
</span>
<span>{label}</span>
</span>
{/* Optional: if you later want a real tooltip instead of title,
you can wire one here using tooltipId. */}
{tooltip ? <span id={tooltipId} className="sr-only">{tooltip}</span> : null}
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
export default function Pagination(props: {
currentPage: number;
totalPages: number;
onPrev: () => void;
onNext: () => void;
}) {
const { currentPage, totalPages, onPrev, onNext } = props;
return (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onPrev}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={onNext}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
);
}
+386
View File
@@ -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 builders 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>
);
}
+162
View File
@@ -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>
</>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import PlatformSwitcher from "./PlatformSwitcher";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type ProductListDto = {
id: string; // your API returns strings sometimes; keep it flexible
name: string;
brand: string;
platform: string;
partRole: string;
categoryKey: string | null;
price: number | null;
buyUrl: string | null;
imageUrl: string | null;
};
function safeSlugify(input: string) {
return (input ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 80);
}
export default function PartsListPageClient(props: {
platform: string;
partRole: string;
}) {
const { platform, partRole } = props;
const [items, setItems] = useState<ProductListDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Simple client-side search (fast + good enough for now)
const [q, setQ] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
setError(null);
// Your current list endpoint:
// GET /api/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>
);
}
+60
View File
@@ -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>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
export default function SortBar(props: {
visibleRange: { start: number; end: number };
totalCount: number;
searchQuery: string;
setSearchQuery: (v: string) => void;
sortBy: SortOption;
setSortBy: (v: SortOption) => void;
}) {
const { visibleRange, totalCount, searchQuery, setSearchQuery, sortBy, setSortBy } = props;
return (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{totalCount}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label htmlFor="part-search" className="text-xs text-zinc-500">
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label htmlFor="sort-by" className="text-xs text-zinc-500">
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div>
);
}