diff --git a/app/(account)/account/page.tsx b/app/(account)/account/page.tsx new file mode 100644 index 0000000..df735d8 --- /dev/null +++ b/app/(account)/account/page.tsx @@ -0,0 +1,44 @@ +// app/(account)/account/page.tsx +"use client"; + +import { useAuth } from "@/context/AuthContext"; + +export default function AccountPage() { + const { user, loading } = useAuth(); + + if (loading) return
Loading…
; + + if (!user) { + return ( +
+ You’re not logged in. Please log in to view your account. +
+ ); + } + + return ( +
+
+

Profile

+

+ Basic account info (we’ll expand this soon). +

+
+ +
+
+ Email:{" "} + {user.email} +
+
+ Display name:{" "} + {user.displayName || "—"} +
+
+ Role:{" "} + {user.role} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/(account)/layout.tsx b/app/(account)/layout.tsx new file mode 100644 index 0000000..a77905a --- /dev/null +++ b/app/(account)/layout.tsx @@ -0,0 +1,60 @@ +// app/(account)/layout.tsx +import Link from "next/link"; + +const nav = [ + { href: "/account", label: "My Account" }, + { href: "/account/settings", label: "Settings" }, +]; + +export default function AccountLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ {/* Header */} +
+
+

Account

+

+ Profile, password, and account settings. +

+
+ + + ← Back to Builder + +
+ +
+ + +
+ {children} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/(builder)/builder/Untitled b/app/(builder)/builder/Untitled deleted file mode 100644 index 2dd6ac0..0000000 --- a/app/(builder)/builder/Untitled +++ /dev/null @@ -1 +0,0 @@ -
\ No newline at end of file diff --git a/app/(builder)/builder/build/page.tsx b/app/(builder)/builder/build/page.tsx deleted file mode 100644 index 0917fbc..0000000 --- a/app/(builder)/builder/build/page.tsx +++ /dev/null @@ -1,482 +0,0 @@ -// -// This page is most likely temporary. We probably do not need this page -// -// -// - -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { useSearchParams, useRouter } from "next/navigation"; - -import { CATEGORIES } from "@/data/gunbuilderParts"; -import type { CategoryId, Part } from "@/types/gunbuilder"; - -import { - Pencil, - Link2, - Square, - CheckSquare, - } from "lucide-react"; - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - -const STORAGE_KEY = "gunbuilder-build-state"; - -type BuildState = Partial>; - -type GunbuilderProductFromApi = { - id: number; - name: string; - brand: string; - platform: string; - partRole: string; - price: number | null; - imageUrl: string | null; - buyUrl: string | null; -}; - -const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; - -const UNIVERSAL_CATEGORIES = new Set([ - "magazine", - "weapon-light", - "foregrip", - "bipod", - "sling", - "rail-accessory", - "tools", - "optic", - "sights", -]); - -const CATEGORY_GROUPS: { - id: string; - label: string; - description?: string; - categoryIds: CategoryId[]; -}[] = [ - { - id: "lower-group", - label: "Lower Receiver Parts", - description: - "Everything from the serialized lower to small parts, fire control, and core controls.", - categoryIds: [ - "lower-receiver", - "complete-lower", - "lower-parts", - "trigger", - "grip", - "safety", - "buffer", - "stock", - ], - }, - { - id: "upper-group", - label: "Upper Receiver Parts", - description: - "Barrel, upper, gas system, and the parts that keep the rifle cycling.", - categoryIds: [ - "upper-receiver", - "complete-upper", - "bcg", - "barrel", - "gas-block", - "gas-tube", - "muzzle-device", - "suppressor", - "handguard", - "charging-handle", - "sights", - "optic", - ], - }, - { - id: "accessories-group", - label: "Accessories & Gear", - description: - "Universal add-ons that work across platforms — mags, lights, slings, tools, and more.", - categoryIds: [ - "magazine", - "weapon-light", - "foregrip", - "bipod", - "sling", - "rail-accessory", - "tools", - ], - }, -]; - -function isValidPlatform( - value: string | null -): value is (typeof PLATFORMS)[number] { - return !!value && (PLATFORMS as readonly string[]).includes(value); -} - -function safeDecodeBuildParam(buildParam: string | null): BuildState | null { - if (!buildParam) return null; - - try { - // build param is base64(json) - const json = window.atob(buildParam); - const parsed = JSON.parse(json) as BuildState; - return parsed && typeof parsed === "object" ? parsed : null; - } catch { - return null; - } -} - -function formatPrice(n: number) { - return `$${n.toFixed(2)}`; -} - -export default function BuildSummaryPage() { - const searchParams = useSearchParams(); - const router = useRouter(); - - const qpPlatform = searchParams.get("platform"); - const platform = isValidPlatform(qpPlatform) ? qpPlatform : "AR-15"; - - const [build, setBuild] = useState({}); - const [parts, setParts] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Load build from ?build=... OR localStorage - useEffect(() => { - if (typeof window === "undefined") return; - - const buildParam = searchParams.get("build"); - const decoded = safeDecodeBuildParam(buildParam); - - if (decoded) { - setBuild(decoded); - return; - } - - try { - const stored = localStorage.getItem(STORAGE_KEY); - setBuild(stored ? (JSON.parse(stored) as BuildState) : {}); - } catch { - setBuild({}); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Fetch product data so we can resolve ids -> Part info - useEffect(() => { - const controller = new AbortController(); - - async function fetchProducts() { - try { - setLoading(true); - setError(null); - - // scoped (platform) - const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent( - platform - )}`; - - // universal (optional) - const universalUrl = `${API_BASE_URL}/api/v1/products`; - - const [scopedRes, universalRes] = await Promise.all([ - fetch(scopedUrl, { signal: controller.signal }), - fetch(universalUrl, { signal: controller.signal }).catch( - () => null as any - ), - ]); - - if (!scopedRes || !scopedRes.ok) { - throw new Error(`Failed to load products (${scopedRes?.status ?? ""})`); - } - - const scopedData: GunbuilderProductFromApi[] = await scopedRes.json(); - - let universalData: GunbuilderProductFromApi[] = []; - if (universalRes && universalRes.ok) { - universalData = await universalRes.json(); - } - - // NOTE: We only need enough to render the summary. - // We will map “everything we can” and ignore unknown categories. - const toCategoryId = (rawRole: string): CategoryId | null => { - const role = (rawRole ?? "").trim().toLowerCase().replace(/_/g, "-"); - - // quick defensive mapping (mirrors your builder page strategy) - if (role.includes("complete-upper")) return "complete-upper"; - if (role.includes("complete-lower")) return "complete-lower"; - if (role === "upper-receiver" || role === "upper") return "upper-receiver"; - if (role === "lower-receiver" || role === "lower") return "lower-receiver"; - if (role.includes("charging")) return "charging-handle"; - if (role.includes("handguard")) return "handguard"; - if (role.includes("bcg") || role.includes("bolt-carrier")) return "bcg"; - if (role.includes("barrel")) return "barrel"; - if (role.includes("gas-block") || role.includes("gasblock")) return "gas-block"; - if (role.includes("gas-tube") || role.includes("gastube")) return "gas-tube"; - if (role.includes("muzzle") || role.includes("flash") || role.includes("brake") || role.includes("comp")) - return "muzzle-device"; - if (role.includes("suppress")) return "suppressor"; - if (role.includes("lower-parts") || role.includes("lpk")) return "lower-parts"; - if (role.includes("trigger")) return "trigger"; - if (role.includes("grip")) return "grip"; - if (role.includes("safety")) return "safety"; - if (role.includes("buffer")) return "buffer"; - if (role.includes("stock")) return "stock"; - if (role.includes("optic") || role.includes("scope")) return "optic"; - if (role.includes("sight")) return "sights"; - if (role.includes("mag")) return "magazine"; - if (role.includes("weapon-light") || (role.includes("light") && !role.includes("flight"))) - return "weapon-light"; - if (role.includes("foregrip") || role.includes("grip-vertical")) return "foregrip"; - if (role.includes("bipod")) return "bipod"; - if (role.includes("sling")) return "sling"; - if (role.includes("rail-accessory") || role.includes("rail-attachment")) return "rail-accessory"; - if (role.includes("tool")) return "tools"; - - return null; - }; - - const normalize = (data: GunbuilderProductFromApi[], isUniversal: boolean): Part[] => - data - .map((p): Part | null => { - const categoryId = toCategoryId(p.partRole); - if (!categoryId) return null; - - if (isUniversal && !UNIVERSAL_CATEGORIES.has(categoryId)) return null; - - const buyUrl = p.buyUrl ?? undefined; - - return { - id: String(p.id), - categoryId, - name: p.name, - brand: p.brand, - price: p.price ?? 0, - imageUrl: (p.imageUrl as any) ?? (p as any).mainImageUrl ?? undefined, - affiliateUrl: buyUrl, - url: buyUrl, - notes: undefined, - }; - }) - .filter((p): p is Part => p !== null); - - const scopedParts = normalize(scopedData, false); - const universalParts = normalize(universalData, true); - - const seen = new Set(); - const merged: Part[] = []; - for (const p of [...scopedParts, ...universalParts]) { - const key = `${p.categoryId}:${p.id}`; - if (seen.has(key)) continue; - seen.add(key); - merged.push(p); - } - - setParts(merged); - } catch (err: any) { - if (err?.name === "AbortError") return; - setError(err?.message ?? "Failed to load products"); - } finally { - setLoading(false); - } - } - - fetchProducts(); - return () => controller.abort(); - }, [platform]); - - const summaryCategoryOrder: CategoryId[] = useMemo( - () => - CATEGORY_GROUPS.flatMap((g) => g.categoryIds).filter((id) => - CATEGORIES.some((c) => c.id === id) - ), - [] - ); - - const selectedParts = useMemo(() => { - const out: { categoryId: CategoryId; part?: Part }[] = []; - for (const cid of summaryCategoryOrder) { - const partId = build[cid]; - const part = partId ? parts.find((p) => p.id === partId && p.categoryId === cid) : undefined; - out.push({ categoryId: cid, part }); - } - return out; - }, [build, parts, summaryCategoryOrder]); - - const total = useMemo( - () => selectedParts.reduce((sum, row) => sum + (row.part?.price ?? 0), 0), - [selectedParts] - ); - - const filledCount = useMemo( - () => selectedParts.filter((r) => !!r.part).length, - [selectedParts] - ); - - return ( -
-
- {/* Header */} -
-
-
-

- BATTL BUILDERS -

-

- Build Summary -

-

- Snapshot of your current selections. (V1: data resolves from your local build state + live products.) -

-
- Platform: {platform} -
-
- -
-
-
- Total -
-
- {formatPrice(total)} -
-
- {filledCount} / {summaryCategoryOrder.length} selected -
-
- -
- - - - Edit Build - -
-
-
-
- - {/* Body */} -
- {loading ? ( -

Loading…

- ) : error ? ( -

- {error} — check that the Ballistic API is running. -

- ) : ( -
- {CATEGORY_GROUPS.map((group) => { - const rows = group.categoryIds - .filter((cid) => summaryCategoryOrder.includes(cid)) - .map((cid) => { - const cat = CATEGORIES.find((c) => c.id === cid); - const partId = build[cid]; - const part = partId - ? parts.find((p) => p.id === partId && p.categoryId === cid) - : undefined; - return { cid, cat, part }; - }); - - return ( -
-
-
{group.label}
- {group.description && ( -
{group.description}
- )} -
- -
- {rows.map(({ cid, cat, part }) => ( -
-
-
- {cat?.name ?? cid} -
- {part ? ( -
- {part.brand}{" "} - {part.name} -
- ) : ( -
- Not selected -
- )} -
- -
-
Price
-
- {part ? formatPrice(part.price) : "—"} -
- -
- - - - - {part?.url ? ( - - - - ) : ( - - — - - )} -
-
-
- ))} -
-
- ); - })} -
- )} -
- -
- Tip: If you landed here from a shared link, it uses ?build= first; otherwise it reads your local build. -
-
-
- ); -} \ No newline at end of file diff --git a/app/(builder)/builder/page.tsx b/app/(builder)/builder/page.tsx index b8e7125..10c552f 100644 --- a/app/(builder)/builder/page.tsx +++ b/app/(builder)/builder/page.tsx @@ -1,3 +1,4 @@ +// app/(builder)/builder/page.tsx "use client"; import { useMemo, useState, useEffect, useCallback, useRef } from "react"; @@ -7,7 +8,7 @@ import { CATEGORIES } from "@/data/gunbuilderParts"; import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; import type { CategoryId, Part } from "@/types/gunbuilder"; import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings"; -import { Pencil, X, Link2, Square, CheckSquare } from "lucide-react"; +import { X, Link2, Square, CheckSquare } from "lucide-react"; // ✅ Centralized overlap rules + helpers import OverlapChip from "@/components/builder/OverlapChip"; @@ -288,10 +289,6 @@ export default function GunbuilderPage() { const handleSelectPart = useCallback( (categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => { - // NOTE: - // - We DO NOT auto-remove any existing selections. - // - We show subtle overlap guidance (chips + an optional “heads up” toast). - const warn = (msg: string) => { setShareStatus(msg); window.setTimeout(() => setShareStatus(null), 4000); @@ -300,7 +297,6 @@ export default function GunbuilderPage() { const hints = getSelectionHints(categoryId, build); if (hints.length > 0) warn(hints[0]); - // ✅ Only set the selection. No deletes. No clearing. setBuild((prev) => ({ ...prev, [categoryId]: partId, @@ -437,6 +433,54 @@ export default function GunbuilderPage() { }); }, [platform]); + // ✅ Load a saved build from Vault via ?load= + useEffect(() => { + const loadUuid = searchParams.get("load"); + if (!loadUuid) return; + + const run = async () => { + try { + setShareStatus("Loading build…"); + + // NOTE: your backend GET route is /api/v1/products/builds/{uuid} + const res = await fetch( + `${API_BASE_URL}/api/v1/products/builds/${encodeURIComponent( + loadUuid + )}`, + { credentials: "include" } + ); + + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`Load failed (${res.status}) ${txt}`); + } + + const dto = await res.json(); // BuildDto w/ items + const nextBuild: Record = {}; + + for (const it of dto.items ?? []) { + if (!it?.slot || !it?.productId) continue; + nextBuild[it.slot] = String(it.productId); + } + + setBuild(nextBuild); + setShareStatus(`Loaded: ${dto.title}`); + + // Clean URL so reload doesn't re-fetch + const qp = new URLSearchParams(searchParams.toString()); + qp.delete("load"); + router.replace(`/builder?${qp.toString()}`, { scroll: false }); + } catch (e: any) { + setShareStatus(e?.message ?? "Failed to load build"); + } finally { + window.setTimeout(() => setShareStatus(null), 4500); + } + }; + + run(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, router]); + // Handle URL query parameters: // - ?select=categoryId:partId // - ?remove=categoryId @@ -527,6 +571,53 @@ export default function GunbuilderPage() { [] ); + // Handler to Save user build to account + const handleSaveToAccount = async () => { + if (selectedParts.length === 0) { + setShareStatus("Add a few parts before saving."); + return; + } + + const title = `${platform} Build`; + + const items = Object.entries(build) + .filter(([, partId]) => !!partId) + .map(([categoryId, partId]) => ({ + productId: Number(partId), + slot: categoryId, + position: 0, + quantity: 1, + })); + + try { + setShareStatus("Saving build…"); + + const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title, + description: null, + isPublic: false, + items, + }), + }); + + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`Save failed (${res.status}) ${txt}`); + } + + const saved = await res.json(); // BuildDto + setShareStatus(`Saved! Build UUID: ${saved.uuid}`); + } catch (e: any) { + setShareStatus(e?.message ?? "Save failed"); + } finally { + window.setTimeout(() => setShareStatus(null), 4500); + } + }; + const handleShare = async () => { if (selectedParts.length === 0) { setShareStatus("Add a few parts before sharing your build."); @@ -680,20 +771,6 @@ export default function GunbuilderPage() {
- - Build Summary - - + + {/* Save Build */} +
@@ -877,14 +968,12 @@ export default function GunbuilderPage() { {groupCategories.map((category) => { - const categoryParts = - partsByCategory[category.id] ?? []; + const categoryParts = partsByCategory[category.id] ?? []; const selectedPartId = build[category.id]; const selectedPart = selectedPartId - ? categoryParts.find( - (p) => p.id === selectedPartId - ) ?? parts.find((p) => p.id === selectedPartId) + ? categoryParts.find((p) => p.id === selectedPartId) ?? + parts.find((p) => p.id === selectedPartId) : undefined; const hasParts = categoryParts.length > 0; @@ -1031,4 +1120,4 @@ export default function GunbuilderPage() {
); -} +} \ No newline at end of file diff --git a/app/(builder)/builder/summary/page.tsx b/app/(builder)/builder/summary/page.tsx deleted file mode 100644 index cbb090c..0000000 --- a/app/(builder)/builder/summary/page.tsx +++ /dev/null @@ -1,460 +0,0 @@ -"use client"; - -import { useMemo, useStat, useEffect } from "react"; -import Link from "next/link"; -import { useSearchParams, useRouter } from "next/navigation"; -import { CATEGORIES } from "@/data/gunbuilderParts"; -import type { CategoryId, Part } from "@/types/gunbuilder"; -import { buildDetailHref } from "@/app/parts/_components/buildDetailHref"; - -type BuildState = Partial>; // categoryId -> partId -const STORAGE_KEY = "gunbuilder-build-state"; - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - -type GunbuilderProductFromApi = { - id: string; // backend UUID string - name: string; - brand: string; - platform: string; - partRole: string; - price: number | null; - mainImageUrl: string | null; - buyUrl: string | null; -}; - -// Map backend partRole -> CategoryId (align with builder categories) -const PART_ROLE_TO_CATEGORY: Record = { - "upper-receiver": "upper", - barrel: "barrel", - handguard: "handguard", - "charging-handle": "chargingHandle", - "buffer-kit": "buffer", - "lower-parts-kit": "lowerParts", - sight: "sights", -}; - -// Inverse: CategoryId -> backend partRole (for building detail URLs) -const CATEGORY_TO_PART_ROLE: Partial> = - Object.fromEntries( - Object.entries(PART_ROLE_TO_CATEGORY).map(([role, cat]) => [cat, role]), - ) as Partial>; - -// Encode build state to URL-friendly string -function encodeBuildState(build: BuildState): string { - const entries = Object.entries(build) - .filter(([_, partId]) => partId) - .map(([categoryId, partId]) => `${categoryId}:${partId}`) - .join(","); - return btoa(entries); -} - -// Decode URL string to build state -function decodeBuildState(encoded: string): BuildState { - try { - const decoded = atob(encoded); - const build: BuildState = {}; - decoded.split(",").forEach((entry) => { - const [categoryId, partId] = entry.split(":"); - if (categoryId && partId) { - build[categoryId as CategoryId] = partId; - } - }); - return build; - } catch { - return {}; - } -} - -export default function BuildDetailsPage() { - const searchParams = useSearchParams(); - const router = useRouter(); - - // ✅ Option A: platform is a query param and is passed through to backend - const platform = searchParams.get("platform") ?? "AR-15"; - - const [build, setBuild] = useState({}); - const [shareUrl, setShareUrl] = useState(""); - const [copied, setCopied] = useState(false); - - const [parts, setParts] = useState([]); - const [loadingParts, setLoadingParts] = useState(true); - const [partsError, setPartsError] = useState(null); - - // Load build from URL params or localStorage - useEffect(() => { - const buildParam = searchParams.get("build"); - if (buildParam) { - const decoded = decodeBuildState(buildParam); - setBuild(decoded); - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded)); - } catch { - // ignore storage failures (private mode, etc.) - } - } else if (typeof window !== "undefined") { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - const parsed = JSON.parse(stored); - setBuild(parsed); - } catch { - // ignore invalid stored data - } - } - } - }, [searchParams]); - - // Fetch live parts from backend (uses selected platform) - useEffect(() => { - const controller = new AbortController(); - - async function fetchProducts() { - try { - setLoadingParts(true); - setPartsError(null); - - const res = await fetch( - `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}`, - { signal: controller.signal }, - ); - - if (!res.ok) { - throw new Error(`Failed to load products (${res.status})`); - } - - const data: GunbuilderProductFromApi[] = await res.json(); - - const normalized: Part[] = data - .map((p): Part | null => { - const categoryId = PART_ROLE_TO_CATEGORY[p.partRole]; - if (!categoryId) return null; - - return { - id: p.id, - categoryId, - name: p.name, - brand: p.brand, - price: p.price ?? 0, - imageUrl: p.mainImageUrl ?? undefined, - url: p.buyUrl ?? undefined, - notes: undefined, - }; - }) - .filter(Boolean) as Part[]; - - setParts(normalized); - } catch (err: any) { - if (err?.name === "AbortError") return; - setPartsError(err?.message ?? "Failed to load products"); - } finally { - setLoadingParts(false); - } - } - - fetchProducts(); - return () => controller.abort(); - }, [platform]); - - // Generate shareable URL (includes platform) - useEffect(() => { - if (typeof window !== "undefined" && Object.keys(build).length > 0) { - const encoded = encodeBuildState(build); - const url = `${window.location.origin}/builder/build?platform=${encodeURIComponent( - platform, - )}&build=${encoded}`; - setShareUrl(url); - } else if (typeof window !== "undefined") { - setShareUrl(""); - } - }, [build, platform]); - - const selectedParts: Part[] = useMemo(() => { - if (parts.length === 0) return []; - return Object.entries(build) - .map(([categoryId, partId]) => - parts.find( - (p) => - p.id === partId && p.categoryId === (categoryId as CategoryId), - ), - ) - .filter(Boolean) as Part[]; - }, [build, parts]); - - const totalPrice = useMemo( - () => selectedParts.reduce((sum, p) => sum + p.price, 0), - [selectedParts], - ); - - const handleCopyLink = async () => { - if (!shareUrl) return; - try { - await navigator.clipboard.writeText(shareUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - }; - - const handleShare = async () => { - if (!shareUrl) return; - - if (navigator.share) { - try { - await navigator.share({ - title: `My ${platform} Build`, - text: `Check out my ${platform} build: $${totalPrice.toFixed(2)}`, - url: shareUrl, - }); - return; - } catch { - // fall back to copy - } - } - - await handleCopyLink(); - }; - - if (!loadingParts && selectedParts.length === 0) { - return ( -
-
-
-

- No Build Selected -

-

- You need to select at least one part to view your build. -

- - Go to Builder - -
-
-
- ); - } - - return ( -
-
- {/* Header */} -
- - ← Back to The Armory - - -
-
-

- Shadow Standard -

-

- Build Summary -

-

- Review your build, purchase parts from our affiliate partners, or - share your build with others. -

-

- Platform: {platform} -

-
- -
-
Total Build Price
-
- ${totalPrice.toFixed(2)} -
-
- {selectedParts.length} / {CATEGORIES.length} parts selected -
-
-
-
- - {/* Share Section */} -
-
-
-

- Share Your Build -

-

- Share this link to let others view your build or bookmark it to - come back later. -

-
-
- - -
-
- - {shareUrl && ( -
-

Shareable URL:

-

- {shareUrl} -

-
- )} -
- - {/* Build Parts */} -
-

- Selected Parts -

- - {loadingParts && ( -

Loading parts…

- )} - {partsError && ( -

- {partsError} — check the Ballistic API. -

- )} - -
- {CATEGORIES.map((category) => { - const selectedPartId = build[category.id]; - const part = parts.find( - (p) => - p.id === selectedPartId && - p.categoryId === (category.id as CategoryId), - ); - - const partRole = - (CATEGORY_TO_PART_ROLE[category.id] as string | undefined) ?? - "unknown"; - - return ( -
-
-
-
- {category.name} -
- - {part ? ( - <> -
- {part.brand}{" "} - {part.name} -
- -
- {part.url && ( - - Purchase from Retailer - - - - - )} - - - View Details - -
- - ) : ( -
Not selected
- )} -
- - {part && ( -
-
- ${part.price.toFixed(2)} -
-
- )} -
-
- ); - })} -
-
- - {/* Actions */} -
- - Edit Build - - -
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx b/app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx index 164b85e..d88d6ac 100644 --- a/app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx +++ b/app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
@@ -256,9 +312,9 @@ export default function ProductDetailsPage() { catalogMappings.

Back to Parts List @@ -276,16 +332,19 @@ export default function ProductDetailsPage() {
- The Armory + Builder / Parts @@ -303,14 +362,17 @@ export default function ProductDetailsPage() { Product Details

- 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.

{/* Platform switch (updates NEW route) */}
-