wired up an accounts page and my builds (my vault)
This commit is contained in:
@@ -5,8 +5,16 @@ 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";
|
||||
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;
|
||||
@@ -23,21 +31,19 @@ type GunbuilderProductFromApi = {
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
|
||||
// image fields can vary depending on endpoint/version
|
||||
// Optional (legacy fallback label if product.offers is missing)
|
||||
merchantName?: string | null;
|
||||
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
|
||||
// single best-link (legacy)
|
||||
buyUrl?: string | null;
|
||||
|
||||
// stock info
|
||||
inStock?: boolean | null;
|
||||
|
||||
// richer details (optional)
|
||||
shortDescription?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
// offers (optional)
|
||||
// New: offers[] from the API
|
||||
offers?: OfferFromApi[] | null;
|
||||
};
|
||||
|
||||
@@ -50,6 +56,10 @@ 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];
|
||||
@@ -62,18 +72,23 @@ function formatPrice(price: number | null | undefined): string {
|
||||
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
|
||||
// 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;
|
||||
|
||||
// merchant name
|
||||
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
|
||||
});
|
||||
}
|
||||
@@ -97,6 +112,10 @@ export default function ProductDetailsPage() {
|
||||
[partRoleParam]
|
||||
);
|
||||
|
||||
/**
|
||||
* categoryId drives builder state selection/removal
|
||||
* (based on partRole -> category mapping).
|
||||
*/
|
||||
const categoryId = useMemo(() => {
|
||||
return (
|
||||
PART_ROLE_TO_CATEGORY[partRoleParam] ??
|
||||
@@ -134,15 +153,22 @@ export default function ProductDetailsPage() {
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||
const selectedPartIdForCategory = categoryId
|
||||
? build?.[categoryId]
|
||||
: undefined;
|
||||
const isSelected =
|
||||
!!categoryId && selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||
!!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);
|
||||
|
||||
// If platform segment is missing/empty, normalize into the new route
|
||||
/**
|
||||
* Route normalization:
|
||||
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (platformParam) return;
|
||||
|
||||
@@ -155,9 +181,19 @@ export default function ProductDetailsPage() {
|
||||
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||
}, [
|
||||
platformParam,
|
||||
platform,
|
||||
partRoleParam,
|
||||
productSlug,
|
||||
router,
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
// Fetch product
|
||||
/**
|
||||
* Fetch product details from API:
|
||||
* GET /api/v1/products/:id
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!numericId) {
|
||||
setError("Invalid product id.");
|
||||
@@ -193,10 +229,14 @@ export default function ProductDetailsPage() {
|
||||
return () => controller.abort();
|
||||
}, [numericId]);
|
||||
|
||||
/**
|
||||
* Builder selection toggle:
|
||||
* - If selected -> remove from build
|
||||
* - Else -> add to build
|
||||
*/
|
||||
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.`
|
||||
@@ -219,9 +259,11 @@ export default function ProductDetailsPage() {
|
||||
|
||||
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
|
||||
|
||||
// Offers normalization:
|
||||
// - If product.offers exists, use it.
|
||||
// - Otherwise, fall back to a single “offer” from product.buyUrl/product.price.
|
||||
/**
|
||||
* 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 [];
|
||||
|
||||
@@ -231,7 +273,7 @@ export default function ProductDetailsPage() {
|
||||
if (product.buyUrl) {
|
||||
return sortOffers([
|
||||
{
|
||||
merchantName: "Merchant",
|
||||
merchantName: product.merchantName ?? "Merchant",
|
||||
price: product.price,
|
||||
buyUrl: product.buyUrl,
|
||||
inStock: product.inStock ?? true,
|
||||
@@ -242,6 +284,20 @@ export default function ProductDetailsPage() {
|
||||
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">
|
||||
@@ -256,9 +312,9 @@ export default function ProductDetailsPage() {
|
||||
<span className="text-zinc-200">catalogMappings</span>.
|
||||
</p>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
href={`/parts/p/${encodeURIComponent(
|
||||
platform
|
||||
)}/${encodeURIComponent(partRoleParam)}`}
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Back to Parts List
|
||||
@@ -276,16 +332,19 @@ export default function ProductDetailsPage() {
|
||||
<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 } : {} }}
|
||||
href={{
|
||||
pathname: "/builder",
|
||||
query: platform ? { platform } : {},
|
||||
}}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
The Armory
|
||||
Builder
|
||||
</Link>
|
||||
<span className="text-zinc-700">/</span>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
href={`/parts/p/${encodeURIComponent(
|
||||
platform
|
||||
)}/${encodeURIComponent(partRoleParam)}`}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
Parts
|
||||
@@ -303,14 +362,17 @@ export default function ProductDetailsPage() {
|
||||
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.
|
||||
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">
|
||||
<label
|
||||
htmlFor="platform-select"
|
||||
className="text-xs text-zinc-500"
|
||||
>
|
||||
Platform
|
||||
</label>
|
||||
<select
|
||||
@@ -388,9 +450,9 @@ export default function ProductDetailsPage() {
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
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
|
||||
@@ -416,13 +478,16 @@ export default function ProductDetailsPage() {
|
||||
<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>
|
||||
<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.
|
||||
We’ll wire this to price snapshots once the offer model is
|
||||
stable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,19 +577,39 @@ export default function ProductDetailsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{offers.map((o, idx) => (
|
||||
<tr key={idx} className="hover:bg-zinc-900/40">
|
||||
<td className="px-3 py-2 text-zinc-200">
|
||||
{o.merchantName ?? "Merchant"}
|
||||
<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
|
||||
</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-red-300">
|
||||
Out of stock
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-300">In 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)}` : "—"}
|
||||
{o.price != null
|
||||
? `$${o.price.toFixed(2)}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{o.buyUrl ? (
|
||||
@@ -537,7 +622,9 @@ export default function ProductDetailsPage() {
|
||||
Buy
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600">—</span>
|
||||
<span className="text-xs text-zinc-600">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -547,21 +634,21 @@ export default function ProductDetailsPage() {
|
||||
</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.
|
||||
No offers attached yet. Once offers are available, this
|
||||
becomes the “where to buy” table.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Keep a single "best" buy CTA if buyUrl exists */}
|
||||
{product.buyUrl ? (
|
||||
{/* Best Offer CTA (uses sorted offers[0]) */}
|
||||
{bestOffer?.buyUrl ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<a
|
||||
href={product.buyUrl}
|
||||
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 Merchant →
|
||||
Buy from {bestOffer.merchantName ?? "Merchant"} →
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -586,4 +673,4 @@ export default function ProductDetailsPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user