wired up an accounts page and my builds (my vault)

This commit is contained in:
2025-12-19 08:05:03 -05:00
parent c6d1bce771
commit b76ced45f1
9 changed files with 566 additions and 1055 deletions
@@ -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, dont 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">
Well wire this to price snapshots once the offer model is stable.
Well 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>
);
}
}