fixed layouts, routing and the platform switcher.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
// app/(builder)/builder/layout.tsx
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||
|
||||
export default async function PartsBrowsePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ partRole: string }>;
|
||||
}) {
|
||||
const { partRole } = await params;
|
||||
|
||||
return (
|
||||
<PartsBrowseClient
|
||||
partRole={decodeURIComponent(partRole)}
|
||||
platform={null} // let client read ?platform=... or default
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import {
|
||||
PART_ROLE_TO_CATEGORY,
|
||||
normalizePartRole,
|
||||
} from "@/lib/catalogMappings";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
imageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
inStock?: boolean | null;
|
||||
};
|
||||
|
||||
type BuildState = Partial<Record<CategoryId, string>>;
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||
|
||||
function extractNumericId(productSlug: string): number | null {
|
||||
if (!productSlug) return null;
|
||||
const first = productSlug.split("-")[0];
|
||||
const n = Number(first);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function formatPrice(price: number | null | undefined): string {
|
||||
if (price == null) return "—";
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function ProductDetailsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// NEW ROUTE PARAMS:
|
||||
const platformParam = String(params.platform ?? "");
|
||||
const partRoleParam = String(params.partRole ?? "");
|
||||
const productSlug = String(params.productSlug ?? "");
|
||||
|
||||
// Platform comes from path segment (fallback to ?platform just in case)
|
||||
const platform =
|
||||
platformParam ||
|
||||
(searchParams.get("platform") as string) ||
|
||||
"AR-15";
|
||||
|
||||
const normalizedRole = useMemo(
|
||||
() => normalizePartRole(partRoleParam),
|
||||
[partRoleParam]
|
||||
);
|
||||
|
||||
const categoryId = useMemo(() => {
|
||||
return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
|
||||
}, [partRoleParam, normalizedRole]);
|
||||
|
||||
const numericId = useMemo(
|
||||
() => extractNumericId(productSlug),
|
||||
[productSlug]
|
||||
);
|
||||
|
||||
// Read-only build state (builder page owns writes)
|
||||
const [build] = useState<BuildState>(() => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||
const isSelected =
|
||||
!!categoryId &&
|
||||
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||
|
||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// If platform segment is missing/empty, normalize into the new route (not /builder)
|
||||
useEffect(() => {
|
||||
if (platformParam) return;
|
||||
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
router.replace(
|
||||
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||
|
||||
// Fetch product
|
||||
useEffect(() => {
|
||||
if (!numericId) {
|
||||
setError("Invalid product id.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi = await res.json();
|
||||
setProduct(data);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
setError(err?.message ?? "Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [numericId]);
|
||||
|
||||
const handleTogglePart = () => {
|
||||
if (!numericId) return;
|
||||
|
||||
// If mapping is missing, don’t send a bogus builder action
|
||||
if (!categoryId) {
|
||||
alert(
|
||||
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
if (isSelected) {
|
||||
qp.set("remove", String(categoryId));
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
qp.set("select", `${categoryId}:${numericId}`);
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
};
|
||||
|
||||
if (!categoryId) {
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||
Unknown Part Role
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-400 mb-6">
|
||||
No mapping found for <span className="text-zinc-200">{partRoleParam}</span>.
|
||||
Add it to <span className="text-zinc-200">catalogMappings</span>.
|
||||
</p>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Back to Parts List
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
{/* Breadcrumbs */}
|
||||
<header className="mb-6">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<Link
|
||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
The Builder
|
||||
</Link>
|
||||
<span className="text-zinc-700">/</span>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
Parts
|
||||
</Link>
|
||||
<span className="text-zinc-700">/</span>
|
||||
<span className="text-zinc-300">Details</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
BATTL BUILDERS
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Product <span className="text-amber-300">Details</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||
Live product data pulled from your Ballistic backend. Add it to your build
|
||||
or jump back to compare options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Platform switch (updates NEW route) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="platform-select" className="text-xs text-zinc-500">
|
||||
Platform
|
||||
</label>
|
||||
<select
|
||||
id="platform-select"
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
router.replace(
|
||||
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}/${encodeURIComponent(productSlug)}`
|
||||
);
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-zinc-500">Loading product…</p>
|
||||
) : error ? (
|
||||
<p className="py-10 text-center text-sm text-red-400">
|
||||
{error} — check that the Ballistic API is running.
|
||||
</p>
|
||||
) : !product ? (
|
||||
<p className="py-10 text-center text-sm text-zinc-500">Product not found.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||
{/* Left: image */}
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||
{product.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={product.imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain p-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
Stock
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold ${
|
||||
product.inStock === false ? "text-red-300" : "text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{product.inStock === false ? "Out of stock" : "In stock"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||
>
|
||||
Back to List
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePart}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: details */}
|
||||
<div className="min-w-0">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
{product.brand}
|
||||
</p>
|
||||
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
|
||||
{product.name}
|
||||
</h2>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
Platform:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
{product.platform || platform}
|
||||
</span>
|
||||
</span>
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
Role:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
{product.partRole || partRoleParam}
|
||||
</span>
|
||||
</span>
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
ID:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
{product.id}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
Current price
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||
{formatPrice(product.price)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePart}
|
||||
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
|
||||
{product.buyUrl ? (
|
||||
<a
|
||||
href={product.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-center text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||
>
|
||||
Buy from Merchant →
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-4 py-2 text-center text-sm text-zinc-500">
|
||||
No buy link available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Notes
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
This details page is intentionally “thin” for now — once we wire in
|
||||
offers, price history, and richer product fields, this section becomes
|
||||
the spec + comparison hub.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||
Tip: use “Back to List” to compare multiple parts quickly.
|
||||
</span>
|
||||
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||
Platform comes from the URL segment:{" "}
|
||||
<span className="text-zinc-300">/parts/p/{platform}/...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// app/builder/[categoryId]/[partId]/data.ts
|
||||
|
||||
// Simulated data functions - Replace these with API calls when ready
|
||||
// Example: export async function getPricingHistory(partId: string) { ... }
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export interface PriceHistoryPoint {
|
||||
date: string; // ISO date string
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface RetailerOffer {
|
||||
id: string;
|
||||
retailerName: string;
|
||||
price: number;
|
||||
originalPrice?: number;
|
||||
inStock: boolean;
|
||||
affiliateUrl: string;
|
||||
lastUpdated: string; // ISO date string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing history for a part
|
||||
* TODO: Replace with API call: GET /api/parts/{partId}/pricing-history
|
||||
*
|
||||
* For now, we keep this simulated.
|
||||
*/
|
||||
export function getPricingHistory(
|
||||
partId: string,
|
||||
basePrice: number,
|
||||
): PriceHistoryPoint[] {
|
||||
// Generate 30 days of price history with some variation
|
||||
const days = 30;
|
||||
const history: PriceHistoryPoint[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
// Simulate price fluctuations (±15% variation)
|
||||
const variation = (Math.random() - 0.5) * 0.3; // -15% to +15%
|
||||
const price = basePrice * (1 + variation);
|
||||
|
||||
history.push({
|
||||
date: date.toISOString().split("T")[0],
|
||||
price: Math.round(price * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retailer offers for a part
|
||||
* Real implementation using Ballistic backend:
|
||||
* GET /api/v1/products/{productId}/offers
|
||||
*/
|
||||
export async function getRetailerOffers(
|
||||
productId: string,
|
||||
): Promise<RetailerOffer[]> {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/products/${productId}/offers`,
|
||||
{
|
||||
// This will be called from the client detail page,
|
||||
// so we *don't* use cache here.
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to load retailer offers (${res.status}) for product ${productId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Expected backend payload (example):
|
||||
// [
|
||||
// {
|
||||
// "id": "uuid-or-int",
|
||||
// "merchantName": "Aero Precision",
|
||||
// "price": 189.99,
|
||||
// "originalPrice": 210.0,
|
||||
// "inStock": true,
|
||||
// "buyUrl": "https://classic.avantlink.com/click.php?...",
|
||||
// "lastUpdated": "2025-11-30T15:22:00Z"
|
||||
// },
|
||||
// ...
|
||||
// ]
|
||||
const data: Array<{
|
||||
id: string | number;
|
||||
merchantName: string;
|
||||
price: number;
|
||||
originalPrice?: number | null;
|
||||
inStock: boolean;
|
||||
buyUrl: string;
|
||||
lastUpdated: string;
|
||||
}> = await res.json();
|
||||
|
||||
const offers: RetailerOffer[] = data
|
||||
.map((o) => ({
|
||||
id: String(o.id),
|
||||
retailerName: o.merchantName,
|
||||
price: o.price,
|
||||
originalPrice: o.originalPrice ?? undefined,
|
||||
inStock: o.inStock,
|
||||
affiliateUrl: o.buyUrl,
|
||||
lastUpdated: o.lastUpdated,
|
||||
}))
|
||||
// Sort by lowest price first
|
||||
.sort((a, b) => a.price - b.price);
|
||||
|
||||
return offers;
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
|
||||
|
||||
type ViewMode = "card" | "list";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
imageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
inStock?: boolean | null;
|
||||
};
|
||||
|
||||
type UiPart = Part & {
|
||||
// local-only stock flag for filtering
|
||||
inStock?: boolean;
|
||||
};
|
||||
|
||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"];
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
// sort options
|
||||
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||
|
||||
// support for url id-slug
|
||||
const slugify = (str: string) =>
|
||||
str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
|
||||
const normalizeRole = (role: string) =>
|
||||
role.trim().toLowerCase().replace(/_/g, "-");
|
||||
|
||||
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
|
||||
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
|
||||
| string[]
|
||||
| undefined;
|
||||
if (!roles || roles.length === 0) return [];
|
||||
return Array.from(new Set(roles.map(normalizeRole)));
|
||||
}
|
||||
|
||||
export default function CategoryPage() {
|
||||
const params = useParams();
|
||||
const categoryId = params.categoryId as CategoryId;
|
||||
const partRoles = useMemo(
|
||||
() => getNormalizedPartRolesForCategory(String(categoryId)),
|
||||
[categoryId]
|
||||
);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [parts, setParts] = useState<UiPart[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// filter + sort state
|
||||
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [priceRange, setPriceRange] = useState<{
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}>({
|
||||
min: null,
|
||||
max: null,
|
||||
});
|
||||
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
// build state for this page (read-only), hydrated from localStorage
|
||||
// NOTE: The main /builder page is the single source of truth for writing to STORAGE_KEY.
|
||||
const [build] = useState<BuildState>(() => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||
const [platform, setPlatform] = useState<string>(initialPlatform);
|
||||
|
||||
// If a user lands here without ?platform=..., normalize the URL so downstream navigation keeps platform.
|
||||
useEffect(() => {
|
||||
const qpPlatform = searchParams.get("platform");
|
||||
if (qpPlatform) return;
|
||||
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", platform || "AR-15");
|
||||
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [categoryId, router, searchParams]);
|
||||
|
||||
const category = useMemo(
|
||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
||||
[categoryId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!categoryId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchCategoryParts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const search = new URLSearchParams();
|
||||
search.set("platform", platform);
|
||||
|
||||
// ✅ Scope results to this category's backend roles
|
||||
for (const r of partRoles) {
|
||||
search.append("partRoles", r);
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products?${search.toString()}`;
|
||||
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load products (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
const normalized: UiPart[] = data.map((p) => ({
|
||||
id: String(p.id), // normalize to string for BuildState
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.imageUrl ?? undefined, // match backend field name
|
||||
url: p.buyUrl ?? undefined,
|
||||
notes: undefined,
|
||||
inStock: p.inStock ?? true,
|
||||
}));
|
||||
|
||||
setParts(normalized);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message ?? "Failed to load products");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategoryParts();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [categoryId, platform, partRoles]);
|
||||
|
||||
// handler to toggle Add / Remove for this category
|
||||
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
|
||||
const isSelected = build[categoryId] === partId;
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
if (isSelected) {
|
||||
// Tell the main builder to remove this category selection
|
||||
qp.set("remove", String(categoryId));
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tell the main builder to add/replace this category selection
|
||||
qp.set("select", `${categoryId}:${partId}`);
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
};
|
||||
|
||||
// available brands for filter
|
||||
const availableBrands = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(parts.map((p) => p.brand)))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
[parts]
|
||||
);
|
||||
|
||||
const priceBounds = useMemo(() => {
|
||||
if (parts.length === 0) {
|
||||
return { min: null as number | null, max: null as number | null };
|
||||
}
|
||||
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const p of parts) {
|
||||
const price = p.price ?? 0;
|
||||
if (price < min) min = price;
|
||||
if (price > max) max = price;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
return { min: null as number | null, max: null as number | null };
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
}, [parts]);
|
||||
|
||||
// initialize / clamp priceRange whenever bounds change
|
||||
useEffect(() => {
|
||||
if (priceBounds.min == null || priceBounds.max == null) return;
|
||||
|
||||
setPriceRange((prev) => {
|
||||
const min = prev.min ?? priceBounds.min!;
|
||||
const max = prev.max ?? priceBounds.max!;
|
||||
return {
|
||||
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
|
||||
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
|
||||
};
|
||||
});
|
||||
}, [priceBounds.min, priceBounds.max]);
|
||||
|
||||
const filteredParts = useMemo(() => {
|
||||
let result = [...parts];
|
||||
|
||||
// Price range filter
|
||||
const effectiveMin =
|
||||
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
|
||||
const effectiveMax =
|
||||
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
|
||||
|
||||
if (effectiveMin != null && effectiveMax != null) {
|
||||
result = result.filter((p) => {
|
||||
const price = p.price ?? 0;
|
||||
return price >= effectiveMin && price <= effectiveMax;
|
||||
});
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if (brandFilter.length > 0) {
|
||||
result = result.filter((p) => brandFilter.includes(p.brand));
|
||||
}
|
||||
|
||||
// In-stock filter
|
||||
if (inStockOnly) {
|
||||
result = result.filter((p) => p.inStock ?? true);
|
||||
}
|
||||
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
if (normalizedQuery) {
|
||||
result = result.filter((p) => {
|
||||
const name = p.name.toLowerCase();
|
||||
const brand = p.brand.toLowerCase();
|
||||
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case "price-asc":
|
||||
result.sort((a, b) => a.price - b.price);
|
||||
break;
|
||||
case "price-desc":
|
||||
result.sort((a, b) => b.price - a.price);
|
||||
break;
|
||||
case "brand-asc":
|
||||
result.sort((a, b) => a.brand.localeCompare(b.brand));
|
||||
break;
|
||||
case "relevance":
|
||||
default:
|
||||
// leave in backend order
|
||||
break;
|
||||
}
|
||||
|
||||
// Pin the currently selected part (for this category) to the top, if any
|
||||
const selectedId = build[categoryId];
|
||||
if (selectedId) {
|
||||
const selected = result.filter((p) => p.id === selectedId);
|
||||
const others = result.filter((p) => p.id !== selectedId);
|
||||
result = [...selected, ...others];
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceBounds, priceRange, inStockOnly]);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() =>
|
||||
filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE),
|
||||
[filteredParts, PAGE_SIZE]
|
||||
);
|
||||
|
||||
const paginatedParts = useMemo(() => {
|
||||
if (filteredParts.length === 0) return [];
|
||||
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
||||
}, [filteredParts, currentPage, PAGE_SIZE]);
|
||||
|
||||
useEffect(() => {
|
||||
// whenever the category or filters change, jump back to page 1
|
||||
setCurrentPage(1);
|
||||
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||
setPlatform(nextPlatform);
|
||||
}, [searchParams]);
|
||||
|
||||
// Keep selection highlight in sync if the build is changed elsewhere and user comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
// Force a re-render by reading current storage (since build state is read-only)
|
||||
// This keeps the selected row pinned/highlighted when navigating back.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
window.localStorage.getItem(STORAGE_KEY);
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
const visibleRange = useMemo(() => {
|
||||
if (filteredParts.length === 0) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
||||
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
|
||||
return { start, end };
|
||||
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||
Category Not Found
|
||||
</h1>
|
||||
<Link
|
||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Return to Builder
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
{/* Header */}
|
||||
<header className="mb-6">
|
||||
<Link
|
||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||
>
|
||||
← Back to the Builder
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Shadow Standard
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{category.name} <span className="text-amber-300">Parts</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
Browse all available {category.name.toLowerCase()} options for your
|
||||
build, pulled live from your Ballistic backend.
|
||||
</p>
|
||||
</div>
|
||||
{!loading && !error && parts.length > 0 && (
|
||||
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("card")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
viewMode === "card"
|
||||
? "bg-zinc-800 text-zinc-50"
|
||||
: "text-zinc-400 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-label="Card view"
|
||||
>
|
||||
Card
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
viewMode === "list"
|
||||
? "bg-zinc-800 text-zinc-50"
|
||||
: "text-zinc-400 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Parts Grid/List */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
{/* Left sidebar: filters */}
|
||||
{!loading && !error && parts.length > 0 && (
|
||||
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Filters
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
Narrow down the {category.name.toLowerCase()} list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price range */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||
Price
|
||||
</span>
|
||||
{priceRange.min !== null || priceRange.max !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPriceRange({ min: priceBounds.min, max: priceBounds.max })
|
||||
}
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{priceBounds.min == null || priceBounds.max == null ? (
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
No price data available.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-zinc-400">
|
||||
<span>
|
||||
Min:{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
${(priceRange.min ?? priceBounds.min).toFixed(0)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Max:{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
${(priceRange.max ?? priceBounds.max).toFixed(0)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min={priceBounds.min ?? 0}
|
||||
max={priceBounds.max ?? 0}
|
||||
value={priceRange.min ?? priceBounds.min ?? 0}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setPriceRange((prev) => ({
|
||||
min: Math.min(value, prev.max ?? priceBounds.max ?? value),
|
||||
max: prev.max ?? priceBounds.max ?? value,
|
||||
}));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={priceBounds.min ?? 0}
|
||||
max={priceBounds.max ?? 0}
|
||||
value={priceRange.max ?? priceBounds.max ?? 0}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setPriceRange((prev) => ({
|
||||
min: prev.min ?? priceBounds.min ?? value,
|
||||
max: Math.max(value, prev.min ?? priceBounds.min ?? value),
|
||||
}));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand multi-select */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||
Brand
|
||||
</span>
|
||||
{brandFilter.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBrandFilter([])}
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{availableBrands.length === 0 ? (
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
No brands available yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
|
||||
{availableBrands.map((b) => {
|
||||
const checked = brandFilter.includes(b);
|
||||
return (
|
||||
<label
|
||||
key={b}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
setBrandFilter((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, b]
|
||||
: prev.filter((brand) => brand !== b)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{b}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{brandFilter.length === 0 && availableBrands.length > 0 && (
|
||||
<p className="mt-1 text-[10px] text-zinc-500">
|
||||
No brands selected — showing all.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* In-stock toggle */}
|
||||
<div className="border-t border-zinc-800 pt-3">
|
||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
|
||||
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||
In stock only
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||
checked={inStockOnly}
|
||||
onChange={(e) => setInStockOnly(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-[10px] text-zinc-500">
|
||||
Hides items marked out of stock by the backend.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Right side: toolbar + results */}
|
||||
<div className="flex-1">
|
||||
{/* Top toolbar: count + search + sort */}
|
||||
{!loading && !error && parts.length > 0 && (
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-xs text-zinc-500">
|
||||
Showing{" "}
|
||||
<span className="font-medium text-zinc-200">
|
||||
{visibleRange.start}-{visibleRange.end}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-medium text-zinc-200">
|
||||
{filteredParts.length}
|
||||
</span>{" "}
|
||||
matching parts
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="part-search" className="text-xs text-zinc-500">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="part-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={`Search ${category.name.toLowerCase()}...`}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="platform-select" className="text-xs text-zinc-500">
|
||||
Platform
|
||||
</label>
|
||||
<select
|
||||
id="platform-select"
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setPlatform(next);
|
||||
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", next);
|
||||
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="sort-by" className="text-xs text-zinc-500">
|
||||
Sort
|
||||
</label>
|
||||
<select
|
||||
id="sort-by"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
<option value="relevance">Relevance</option>
|
||||
<option value="price-asc">Price: Low → High</option>
|
||||
<option value="price-desc">Price: High → Low</option>
|
||||
<option value="brand-asc">Brand: A → Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results / states */}
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-500">
|
||||
Loading {category.name.toLowerCase()}…
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-sm text-red-400">
|
||||
{error} — check that the Ballistic API is running.
|
||||
</p>
|
||||
) : filteredParts.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-500">
|
||||
No parts available for this category yet.
|
||||
</p>
|
||||
) : viewMode === "card" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedParts.map((part) => {
|
||||
const isSelected = build[categoryId] === part.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
||||
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2 justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-zinc-50">
|
||||
{part.brand}{" "}
|
||||
<span className="font-normal">— {part.name}</span>
|
||||
</div>
|
||||
{part.notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
|
||||
{part.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
|
||||
${part.price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/builder/${categoryId}/${part.id}-${slugify(
|
||||
part.name
|
||||
)}`,
|
||||
query: { platform },
|
||||
}}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row for list view */}
|
||||
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
|
||||
<span>Part</span>
|
||||
<span>Brand</span>
|
||||
<span className="text-right">Price</span>
|
||||
<span className="pr-2 text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedParts.map((part) => {
|
||||
const isSelected = build[categoryId] === part.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
className={`group relative rounded-md border p-3 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
||||
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-zinc-50">
|
||||
{part.name}
|
||||
</div>
|
||||
{part.notes && (
|
||||
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
|
||||
{part.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
|
||||
{part.brand}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-amber-300 md:text-right">
|
||||
${part.price.toFixed(2)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/builder/${categoryId}/${part.id}-${slugify(
|
||||
part.name
|
||||
)}`,
|
||||
query: { platform },
|
||||
}}
|
||||
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{filteredParts.length > 0 && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
|
||||
<div>
|
||||
Page{" "}
|
||||
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-semibold text-zinc-200">{totalPages}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function PartsRoleRedirectPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { partRole: string };
|
||||
searchParams?: { platform?: string };
|
||||
}) {
|
||||
const partRole = params.partRole;
|
||||
const platform = searchParams?.platform ?? "AR-15";
|
||||
|
||||
redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// app/(builder)/parts/_components/buildDetailHref.ts
|
||||
|
||||
const toSlug = (str: string) =>
|
||||
str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
|
||||
export type BuildDetailInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
};
|
||||
|
||||
export function buildDetailHref(
|
||||
platform: string,
|
||||
partRole: string,
|
||||
p: BuildDetailInput
|
||||
) {
|
||||
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
|
||||
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRole
|
||||
)}/${p.id}-${slug}`;
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import {
|
||||
PART_ROLE_TO_CATEGORY,
|
||||
normalizePartRole,
|
||||
} from "@/lib/catalogMappings";
|
||||
|
||||
/**
|
||||
* API Shapes
|
||||
* - OfferFromApi: what /api/v1/products/:id returns in offers[]
|
||||
* - GunbuilderProductFromApi: the product detail contract (v1)
|
||||
*/
|
||||
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;
|
||||
|
||||
// Optional (legacy fallback label if product.offers is missing)
|
||||
merchantName?: string | null;
|
||||
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
|
||||
buyUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
|
||||
shortDescription?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
// New: offers[] from the API
|
||||
offers?: OfferFromApi[] | null;
|
||||
};
|
||||
|
||||
type BuildState = Partial<Record<CategoryId, string>>;
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||
|
||||
/**
|
||||
* Extract numeric id from slug like: "1217-radian-ar-15..."
|
||||
* This keeps URLs pretty while still letting us fetch by ID.
|
||||
*/
|
||||
function extractNumericId(productSlug: string): number | null {
|
||||
if (!productSlug) return null;
|
||||
const first = productSlug.split("-")[0];
|
||||
const n = Number(first);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function formatPrice(price: number | null | undefined): string {
|
||||
if (price == null) return "—";
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort offers:
|
||||
* 1) lowest price first
|
||||
* 2) in-stock before out-of-stock
|
||||
* 3) merchant name tiebreaker
|
||||
*/
|
||||
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
||||
return [...offers].sort((a, b) => {
|
||||
const ap = a.price ?? Number.POSITIVE_INFINITY;
|
||||
const bp = b.price ?? Number.POSITIVE_INFINITY;
|
||||
if (ap !== bp) return ap - bp;
|
||||
|
||||
// in-stock first (false sorts later)
|
||||
const aStock = a.inStock === false ? 1 : 0;
|
||||
const bStock = b.inStock === false ? 1 : 0;
|
||||
if (aStock !== bStock) return aStock - bStock;
|
||||
|
||||
return (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]
|
||||
);
|
||||
|
||||
/**
|
||||
* categoryId drives builder state selection/removal
|
||||
* (based on partRole -> category mapping).
|
||||
*/
|
||||
const categoryId = useMemo(() => {
|
||||
return (
|
||||
PART_ROLE_TO_CATEGORY[partRoleParam] ??
|
||||
PART_ROLE_TO_CATEGORY[normalizedRole] ??
|
||||
null
|
||||
);
|
||||
}, [partRoleParam, normalizedRole]);
|
||||
|
||||
const numericId = useMemo(() => extractNumericId(productSlug), [productSlug]);
|
||||
|
||||
// Read-only build state (builder page owns writes)
|
||||
const [build, setBuild] = useState<BuildState>(() => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// Keep build in sync if user changes build elsewhere
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
const selectedPartIdForCategory = categoryId
|
||||
? build?.[categoryId]
|
||||
: undefined;
|
||||
const isSelected =
|
||||
!!categoryId &&
|
||||
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||
|
||||
// Product fetch state
|
||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Route normalization:
|
||||
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (platformParam) return;
|
||||
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
router.replace(
|
||||
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
platformParam,
|
||||
platform,
|
||||
partRoleParam,
|
||||
productSlug,
|
||||
router,
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fetch product details from API:
|
||||
* GET /api/v1/products/:id
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!numericId) {
|
||||
setError("Invalid product id.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi = await res.json();
|
||||
setProduct(data);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
setError(err?.message ?? "Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct();
|
||||
return () => controller.abort();
|
||||
}, [numericId]);
|
||||
|
||||
/**
|
||||
* Builder selection toggle:
|
||||
* - If selected -> remove from build
|
||||
* - Else -> add to build
|
||||
*/
|
||||
const handleTogglePart = () => {
|
||||
if (!numericId) return;
|
||||
|
||||
if (!categoryId) {
|
||||
alert(
|
||||
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
if (isSelected) {
|
||||
qp.set("remove", String(categoryId));
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
qp.set("select", `${categoryId}:${numericId}`);
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
};
|
||||
|
||||
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
|
||||
|
||||
/**
|
||||
* Offers normalization:
|
||||
* - If product.offers exists, use & sort it (real offers).
|
||||
* - Otherwise, fallback to a single offer derived from product.buyUrl/product.price (legacy).
|
||||
*/
|
||||
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: product.merchantName ?? "Merchant",
|
||||
price: product.price,
|
||||
buyUrl: product.buyUrl,
|
||||
inStock: product.inStock ?? true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [product]);
|
||||
|
||||
/**
|
||||
* Best offer (for the "Buy from ..." CTA)
|
||||
* IMPORTANT: this must be defined AFTER offers is computed
|
||||
* (do NOT reference `offers` inside the offers useMemo).
|
||||
*/
|
||||
const bestOffer = offers[0] ?? null;
|
||||
|
||||
/**
|
||||
* Helper: identify the best offer row
|
||||
* We compare by buyUrl (stable + unique enough for MVP)
|
||||
*/
|
||||
const isBestOffer = (offer: OfferFromApi) =>
|
||||
!!bestOffer && offer.buyUrl && offer.buyUrl === bestOffer.buyUrl;
|
||||
|
||||
if (!categoryId) {
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||
Unknown Part Role
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-400 mb-6">
|
||||
No mapping found for{" "}
|
||||
<span className="text-zinc-200">{partRoleParam}</span>. Add it to{" "}
|
||||
<span className="text-zinc-200">catalogMappings</span>.
|
||||
</p>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(
|
||||
platform
|
||||
)}/${encodeURIComponent(partRoleParam)}`}
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Back to Parts List
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
{/* Breadcrumbs */}
|
||||
<header className="mb-6">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/builder",
|
||||
query: platform ? { platform } : {},
|
||||
}}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
Builder
|
||||
</Link>
|
||||
<span className="text-zinc-700">/</span>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(
|
||||
platform
|
||||
)}/${encodeURIComponent(partRoleParam)}`}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
Parts
|
||||
</Link>
|
||||
<span className="text-zinc-700">/</span>
|
||||
<span className="text-zinc-300">Details</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
BATTL BUILDERS
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Product <span className="text-amber-300">Details</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||
Offers, pricing placeholders, and builder actions — all on the
|
||||
canonical /parts/p route.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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 ${
|
||||
isBestOffer(o)
|
||||
? "bg-amber-400/10 border-l-2 border-amber-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{" "}
|
||||
<td className="px-3 py-2 text-zinc-200 flex items-center">
|
||||
<span>{o.merchantName ?? "Merchant"}</span>
|
||||
|
||||
{isBestOffer(o) && (
|
||||
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
|
||||
BEST DEAL
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{o.inStock === false ? (
|
||||
<span className="text-red-300">
|
||||
Out of stock
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-300">
|
||||
In stock
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
||||
{o.price != null
|
||||
? `$${o.price.toFixed(2)}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{o.buyUrl ? (
|
||||
<a
|
||||
href={o.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
||||
>
|
||||
Buy
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-zinc-500">
|
||||
No offers attached yet. Once offers are available, this
|
||||
becomes the “where to buy” table.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Best Offer CTA (uses sorted offers[0]) */}
|
||||
{bestOffer?.buyUrl ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<a
|
||||
href={bestOffer.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||
>
|
||||
Buy from {bestOffer.merchantName ?? "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} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// app/(builder)/layout.tsx
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { BuilderNav } from "@/components/BuilderNav";
|
||||
import { TopNav } from "@/components/TopNav";
|
||||
|
||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
|
||||
<TopNav />
|
||||
<BuilderNav />
|
||||
<main className="min-h-screen">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useApi } from "@/lib/api";
|
||||
|
||||
type BuildDto = {
|
||||
uuid: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
items?: Array<{
|
||||
slot: string;
|
||||
productId: number;
|
||||
productName?: string | null;
|
||||
productBrand?: string | null;
|
||||
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
|
||||
}>;
|
||||
photos?: BuildPhotoDto[];
|
||||
};
|
||||
|
||||
type BuildPhotoDto = {
|
||||
uuid: string;
|
||||
url: string;
|
||||
caption?: string | null;
|
||||
sortOrder?: number | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
type LocalPreview = {
|
||||
id: string;
|
||||
file: File;
|
||||
url: string; // object URL
|
||||
};
|
||||
|
||||
function fmt(d?: string | null) {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d);
|
||||
return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
|
||||
}
|
||||
|
||||
export default function VaultBuildEditPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const router = useRouter();
|
||||
const api = useApi();
|
||||
const { token, loading: authLoading } = useAuth();
|
||||
|
||||
const [build, setBuild] = useState<BuildDto | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||
|
||||
// photo upload UI state
|
||||
const [previews, setPreviews] = useState<LocalPreview[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// avoid object URL leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previews.forEach((p) => URL.revokeObjectURL(p.url));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const authed = !!token;
|
||||
|
||||
// Load build once auth is ready + token exists
|
||||
useEffect(() => {
|
||||
if (!uuid) return;
|
||||
if (authLoading) return;
|
||||
|
||||
if (!authed) {
|
||||
setError("Please log in to edit builds.");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await api.get(
|
||||
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `Failed to load build (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BuildDto;
|
||||
|
||||
if (cancelled) return;
|
||||
setBuild(data);
|
||||
setForm({
|
||||
title: data.title ?? "",
|
||||
description: data.description ?? "",
|
||||
isPublic: Boolean(data.isPublic),
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message ?? "Failed to load build");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [uuid, authLoading, authed, api]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!build) return false;
|
||||
return (
|
||||
form.title !== (build.title ?? "") ||
|
||||
form.description !== (build.description ?? "") ||
|
||||
form.isPublic !== Boolean(build.isPublic)
|
||||
);
|
||||
}, [build, form]);
|
||||
|
||||
async function onSave() {
|
||||
if (!uuid) return;
|
||||
setBusy(true);
|
||||
setSavedMsg(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title.trim() || "Untitled Build",
|
||||
description: form.description.trim() || null,
|
||||
isPublic: form.isPublic,
|
||||
};
|
||||
|
||||
const res = await api.put(
|
||||
`/api/v1/builds/me/${encodeURIComponent(uuid)}`,
|
||||
payload
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `Save failed (${res.status})`);
|
||||
}
|
||||
|
||||
const updated = (await res.json()) as BuildDto;
|
||||
setBuild(updated);
|
||||
setForm({
|
||||
title: updated.title ?? "",
|
||||
description: updated.description ?? "",
|
||||
isPublic: Boolean(updated.isPublic),
|
||||
});
|
||||
setSavedMsg("Saved.");
|
||||
setTimeout(() => setSavedMsg(null), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function isImage(file: File) {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
async function fileToUpload(file: File, buildUuid: string) {
|
||||
// 1) ask backend for presigned upload URL
|
||||
const res = await api.post(
|
||||
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`,
|
||||
{
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text().catch(() => "Failed to get upload URL"));
|
||||
}
|
||||
|
||||
const { uploadUrl, publicUrl } = await res.json();
|
||||
|
||||
// 2) upload directly to storage
|
||||
const put = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!put.ok) throw new Error("Upload failed");
|
||||
|
||||
// 3) confirm + create photo record
|
||||
const create = await api.post(
|
||||
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`,
|
||||
{
|
||||
url: publicUrl,
|
||||
caption: null,
|
||||
sortOrder: 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (!create.ok) {
|
||||
throw new Error(await create.text().catch(() => "Failed to save photo"));
|
||||
}
|
||||
|
||||
return (await create.json()) as BuildPhotoDto;
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!uuid) return;
|
||||
|
||||
const ok = window.confirm("Delete this build?\n\nThis cannot be undone.");
|
||||
if (!ok) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await api.del(
|
||||
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `Delete failed (${res.status})`);
|
||||
}
|
||||
|
||||
router.replace("/vault");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Delete failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Edit Build</h1>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
UUID: <span className="font-mono">{uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/vault"
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
|
||||
>
|
||||
← Vault
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||||
disabled={!uuid}
|
||||
onClick={() =>
|
||||
router.push(`/builder?load=${encodeURIComponent(uuid)}`)
|
||||
}
|
||||
title="Open this build in the builder to swap parts"
|
||||
>
|
||||
Open in Builder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authLoading && <div className="text-sm text-zinc-400">Loading…</div>}
|
||||
|
||||
{!authLoading && error && (
|
||||
<pre className="mb-4 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!authLoading && build && (
|
||||
<div className="space-y-4">
|
||||
{/* meta */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs text-zinc-400">
|
||||
Created: {fmt(build.createdAt)} • Updated:{" "}
|
||||
{fmt(build.updatedAt)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] ${
|
||||
form.isPublic
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-white/10 bg-white/5 text-white/70"
|
||||
}`}
|
||||
>
|
||||
{form.isPublic ? "Published" : "Draft"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* parts */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">Parts in this build</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{build.items?.length ? `${build.items.length} items` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!build.items?.length ? (
|
||||
<div className="text-sm opacity-70">No parts saved yet.</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-white/10 bg-black/20">
|
||||
{/* header */}
|
||||
<div className="grid grid-cols-12 gap-3 border-b border-white/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
|
||||
<div className="col-span-5">Product</div>
|
||||
<div className="col-span-4">Component (Role)</div>
|
||||
<div className="col-span-2">Brand</div>
|
||||
<div className="col-span-1 text-right">Price</div>
|
||||
</div>
|
||||
|
||||
{/* rows */}
|
||||
{build.items
|
||||
.slice()
|
||||
.sort((a, b) => a.slot.localeCompare(b.slot))
|
||||
.map((it, idx) => {
|
||||
const price =
|
||||
typeof it.bestPrice === "number"
|
||||
? it.bestPrice
|
||||
: it.bestPrice
|
||||
? Number(it.bestPrice)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${it.slot}-${it.productId}-${idx}`}
|
||||
className="grid grid-cols-12 gap-2 border-b border-white/5 px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
{/* Product */}
|
||||
<div className="col-span-5 min-w-0">
|
||||
<div className="truncate text-zinc-100">
|
||||
{it.productName ?? `Product #${it.productId}`}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
ID:{" "}
|
||||
<span className="font-mono">{it.productId}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component (Role) */}
|
||||
<div className="col-span-4 truncate text-zinc-200">
|
||||
{it.slot}
|
||||
</div>
|
||||
|
||||
{/* Brand */}
|
||||
<div className="col-span-2 truncate text-zinc-300">
|
||||
{it.productBrand ?? "—"}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="col-span-1 text-right font-semibold text-zinc-100">
|
||||
{price != null && !Number.isNaN(price)
|
||||
? `$${price.toFixed(2)}`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* form */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<label className="block text-xs font-medium text-zinc-300">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, title: e.target.value }))
|
||||
}
|
||||
className="mt-2 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
|
||||
placeholder="e.g. Suppressed MK2"
|
||||
maxLength={120}
|
||||
/>
|
||||
|
||||
<label className="mt-4 block text-xs font-medium text-zinc-300">
|
||||
Notes / Description
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, description: e.target.value }))
|
||||
}
|
||||
className="mt-2 min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
|
||||
placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPublic}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, isPublic: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
<span className="text-zinc-200">Publish to community</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{savedMsg && (
|
||||
<span className="text-xs text-emerald-300">{savedMsg}</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={busy || !dirty}
|
||||
className="rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black hover:bg-zinc-200 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Working…" : dirty ? "Save" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Photos */}
|
||||
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Build Photos</div>
|
||||
<div className="text-xs opacity-70">
|
||||
Select photos, remove any you don’t want, then upload.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Select (no upload) */}
|
||||
<label
|
||||
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
|
||||
uploading ? "opacity-60 pointer-events-none" : ""
|
||||
}`}
|
||||
title={uploading ? "Uploading…" : "Select photos"}
|
||||
>
|
||||
Select Photos
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []).filter(
|
||||
isImage
|
||||
);
|
||||
if (!files.length) return;
|
||||
|
||||
const local: LocalPreview[] = files.map((f) => ({
|
||||
id:
|
||||
typeof crypto !== "undefined" &&
|
||||
"randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random()}`,
|
||||
file: f,
|
||||
url: URL.createObjectURL(f),
|
||||
}));
|
||||
|
||||
setPreviews((prev) => [...local, ...prev]);
|
||||
e.target.value = ""; // reset input so re-selecting same file works
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Upload (real upload) */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading || previews.length === 0 || !build?.uuid}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
if (!build?.uuid || previews.length === 0) return;
|
||||
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
|
||||
// snapshot so user can keep selecting more while current batch uploads
|
||||
const batch = [...previews];
|
||||
|
||||
try {
|
||||
for (const p of batch) {
|
||||
// if user removed it before we got to it, skip
|
||||
const stillThere = previews.some((x) => x.id === p.id);
|
||||
if (!stillThere) continue;
|
||||
|
||||
const created = await fileToUpload(p.file, build.uuid);
|
||||
|
||||
setBuild((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
photos: [created, ...(prev.photos ?? [])],
|
||||
}
|
||||
: prev
|
||||
);
|
||||
|
||||
// remove preview after success
|
||||
setPreviews((prev) => {
|
||||
const hit = prev.find((x) => x.id === p.id);
|
||||
if (hit) URL.revokeObjectURL(hit.url);
|
||||
return prev.filter((x) => x.id !== p.id);
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? "Photo upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{uploading
|
||||
? "Uploading…"
|
||||
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{/* Local previews (pending upload) */}
|
||||
{previews.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||
>
|
||||
{/* remove X */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
|
||||
onClick={() => {
|
||||
setPreviews((prev) => {
|
||||
const hit = prev.find((x) => x.id === p.id);
|
||||
if (hit) URL.revokeObjectURL(hit.url);
|
||||
return prev.filter((x) => x.id !== p.id);
|
||||
});
|
||||
}}
|
||||
disabled={uploading}
|
||||
title={
|
||||
uploading
|
||||
? "Wait for upload to finish"
|
||||
: "Remove from upload list"
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={p.url}
|
||||
alt="Pending upload"
|
||||
className="h-32 w-full object-cover"
|
||||
/>
|
||||
<div className="p-2 text-xs opacity-80">
|
||||
{uploading ? "Uploading…" : "Pending upload"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Uploaded photos */}
|
||||
{(build?.photos ?? []).map((p) => (
|
||||
<div
|
||||
key={p.uuid}
|
||||
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||
>
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.caption ?? "Build photo"}
|
||||
className="h-32 w-full object-cover"
|
||||
/>
|
||||
<div className="p-2 text-xs opacity-80 line-clamp-2">
|
||||
{p.caption ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
|
||||
<div className="col-span-full text-xs opacity-70">
|
||||
No photos yet. Select a few and upload when ready.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* danger zone */}
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
||||
<div className="text-sm font-semibold text-red-200">
|
||||
Danger zone
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-red-200/70">
|
||||
Deleting a build is permanent.
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-100 hover:bg-red-500/15 disabled:opacity-50"
|
||||
>
|
||||
Delete build
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// app/vault/page.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useApi } from "@/lib/api";
|
||||
|
||||
type BuildDto = {
|
||||
id: string; // internal numeric id (not used by UI)
|
||||
uuid: string; // public identifier
|
||||
title: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
// UUID validator (defensive)
|
||||
const isUuid = (v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
v
|
||||
);
|
||||
|
||||
function fmt(d?: string | null) {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d);
|
||||
if (Number.isNaN(dt.getTime())) return d;
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
|
||||
export default function VaultPage() {
|
||||
const router = useRouter();
|
||||
const api = useApi();
|
||||
|
||||
// NOTE:
|
||||
// - `loading` here is AuthContext hydration, not network.
|
||||
// - `token` is the real gate for /me endpoints.
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
|
||||
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const authed = !!user && !!token;
|
||||
|
||||
// Redirect if not logged in (after auth hydrates)
|
||||
useEffect(() => {
|
||||
if (!authLoading && !authed) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [authLoading, authed, router]);
|
||||
|
||||
// When auth identity changes, clear stale list to avoid “ghost builds”
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
setBuilds([]);
|
||||
setError(null);
|
||||
}, [authLoading, user?.uuid, token]);
|
||||
|
||||
// Fetch user builds
|
||||
useEffect(() => {
|
||||
if (authLoading || !authed) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// IMPORTANT:
|
||||
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
|
||||
const res = await api.get("/api/v1/builds/me");
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BuildDto[];
|
||||
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || "Failed to load builds");
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authLoading, authed, api]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<div className="text-sm opacity-70">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Already redirected
|
||||
if (!authed) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Vault</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
Your saved builds. Open one to keep tweaking parts, or add details
|
||||
to submit it to the community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/builder"
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
|
||||
>
|
||||
New Build
|
||||
</Link>
|
||||
<Link href="/account" className="text-sm opacity-80 hover:underline">
|
||||
Account →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Builds list */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<div className="text-sm font-medium">My Builds</div>
|
||||
<div className="text-xs opacity-70">
|
||||
{busy ? "Refreshing…" : `${builds.length} total`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{builds.length === 0 ? (
|
||||
<div className="p-4 text-sm opacity-70">
|
||||
No builds yet. Create one in the builder and hit “Save As…”
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/10">
|
||||
{builds.map((b) => {
|
||||
const valid = !!b?.uuid && isUuid(String(b.uuid));
|
||||
const published = Boolean(b.isPublic);
|
||||
|
||||
const openHref = valid
|
||||
? `/builder?load=${encodeURIComponent(b.uuid)}`
|
||||
: "#";
|
||||
|
||||
const detailsHref = valid
|
||||
? `/vault/${encodeURIComponent(b.uuid)}/edit`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={b.uuid}
|
||||
className="p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium truncate">{b.title}</div>
|
||||
|
||||
{/* Status chip */}
|
||||
<span
|
||||
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${
|
||||
published
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-white/10 bg-white/5 text-white/70"
|
||||
}`}
|
||||
title={
|
||||
published
|
||||
? "This build is public (community-visible)."
|
||||
: "Draft (only visible in your Vault)."
|
||||
}
|
||||
>
|
||||
{published ? "Published" : "Draft"}
|
||||
</span>
|
||||
|
||||
{!valid && (
|
||||
<span
|
||||
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
|
||||
title="This build record has an invalid UUID."
|
||||
>
|
||||
Invalid UUID
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs opacity-70">
|
||||
Updated: {fmt(b.updatedAt)} • UUID:{" "}
|
||||
<span className="font-mono">{b.uuid}</span>
|
||||
</div>
|
||||
|
||||
{b.description && (
|
||||
<div className="mt-2 text-sm opacity-80 line-clamp-2">
|
||||
{b.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Link
|
||||
href={openHref}
|
||||
aria-disabled={!valid}
|
||||
tabIndex={!valid ? -1 : 0}
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||
valid
|
||||
? "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
||||
}`}
|
||||
title={valid ? "Load this build into the builder" : "Invalid build id"}
|
||||
>
|
||||
View in Builder
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={detailsHref}
|
||||
aria-disabled={!valid}
|
||||
tabIndex={!valid ? -1 : 0}
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||
valid
|
||||
? "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
||||
}`}
|
||||
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
||||
>
|
||||
Edit Title
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user