From 607939c468982680012e9dc67a66d38194c55ae9 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 15 Dec 2025 20:59:19 -0500 Subject: [PATCH] a metric shit ton. fixed a lot of part_role issues. removed almost all hardcoded categories and used db roles. --- .../builder/[categoryId]/[partId]/page.tsx | 438 ---------- app/(builder)/builder/layout.tsx | 26 +- app/(builder)/builder/page.tsx | 562 ++++++++---- app/(builder)/layout.tsx | 13 +- .../[category]}/[partId]/data.ts | 0 .../[category]/[partId]}/page.tsx | 197 ++--- app/(builder)/parts/[category]/page.tsx | 802 ++++++++++++++++++ app/admin/mapping/page.tsx | 229 +++-- app/admin/merchants/page.tsx | 126 ++- components/BuilderNav.tsx | 6 +- components/CategoryColumn.tsx | 87 -- components/PricingHistoryGraph.tsx | 2 +- components/RetailersList.tsx | 2 +- data/gunbuilderParts.ts | 39 +- data/partRoleMappings.ts | 66 +- types/gunbuilder.ts | 8 +- 16 files changed, 1538 insertions(+), 1065 deletions(-) delete mode 100644 app/(builder)/builder/[categoryId]/[partId]/page.tsx rename app/(builder)/{builder/[categoryId] => parts/[category]}/[partId]/data.ts (100%) rename app/(builder)/{builder/[categoryId] => parts/[category]/[partId]}/page.tsx (88%) create mode 100644 app/(builder)/parts/[category]/page.tsx delete mode 100644 components/CategoryColumn.tsx diff --git a/app/(builder)/builder/[categoryId]/[partId]/page.tsx b/app/(builder)/builder/[categoryId]/[partId]/page.tsx deleted file mode 100644 index 7bde3b4..0000000 --- a/app/(builder)/builder/[categoryId]/[partId]/page.tsx +++ /dev/null @@ -1,438 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { CATEGORIES } from "@/data/gunbuilderParts"; -import type { CategoryId } from "@/types/gunbuilder"; -import { - getRetailerOffers, - type RetailerOffer, -} from "./data"; - -type GunbuilderProductFromApi = { - id: string; // backend UUID string - name: string; - brand: string; - platform: string; - partRole: string; - price: number | null; - imageUrl: string | null; - buyUrl: string | null; -}; - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - -export default function PartDetailPage() { - const params = useParams(); - const categoryId = params.categoryId as CategoryId; - - // Support URLs like /builder/lower/25969-m5-complete-lower-receiver - const rawPartParam = params.partId as string; - const partId = rawPartParam.split("-")[0]; // "25969-m5-..." -> "25969" - - const [product, setProduct] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const [offers, setOffers] = useState([]); - const [offersLoading, setOffersLoading] = useState(true); - const [offersError, setOffersError] = useState(null); - - const category = useMemo( - () => CATEGORIES.find((c) => c.id === categoryId), - [categoryId], - ); - - // 1) Load product (same as before) - useEffect(() => { - if (!partId) return; - - const controller = new AbortController(); - - async function fetchProduct() { - try { - setLoading(true); - setError(null); - - // For now, pull the full AR-15 list and find by UUID. - // We can optimize with a dedicated /api/products/{uuid} later. - const url = `${API_BASE_URL}/api/products/gunbuilder/products/${partId}`; - 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(); - - if (!data) { - setError("Product not found"); - } - - setProduct(data); - } catch (err: any) { - if (err.name === "AbortError") return; - setError(err.message ?? "Failed to load product"); - } finally { - setLoading(false); - } - } - - fetchProduct(); - - return () => controller.abort(); - }, [partId]); - - // 2) Load offers for this product from Ballistic backend - useEffect(() => { - if (!partId) return; - - let cancelled = false; - - async function fetchOffers() { - try { - setOffersLoading(true); - setOffersError(null); - - const data = await getRetailerOffers(partId); - - if (!cancelled) { - setOffers(data); - } - } catch (err: any) { - if (!cancelled) { - setOffersError(err.message ?? "Failed to load retailer offers"); - } - } finally { - if (!cancelled) { - setOffersLoading(false); - } - } - } - - fetchOffers(); - - return () => { - cancelled = true; - }; - }, [partId]); - - // Best price: prefer live offers, fall back to summary product.price - const bestPrice = useMemo(() => { - if (offers.length > 0) { - return offers[0].price; - } - return product?.price ?? null; - }, [offers, product]); - - if (!category) { - return ( -
-
-

Category Not Found

- - Return to Builder - -
-
- ); - } - - return ( -
-
- {/* Breadcrumbs */} - - - {loading ? ( -

Loading product…

- ) : error || !product ? ( -
-

Product Unavailable

-

- {error ?? "We couldn’t find this product."} -

- - Back to {category.name} parts - -
- ) : ( - <> -
- {/* Left: image + meta */} -
- {product.imageUrl && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {product.name} -
- )} -
- {product.brand} -
-

- {product.name} -

- -
-

- Platform:{" "} - {product.platform} -

-

- Role:{" "} - {product.partRole} -

-

- Category:{" "} - {category.name} -

-

- Offers:{" "} - - {offers.length > 0 - ? `${offers.length} live offer${ - offers.length === 1 ? "" : "s" - }` - : "No live offers yet"} - -

-
- - {/* Placeholder content until long-form fields are wired up */} -
-

- Product Overview -

-

- This is placeholder copy for the product overview. Once - the Ballistic backend exposes richer metadata (short - description, feature bullets, etc.), we'll swap this - text out and surface the real content here. -

-

- Use this section to highlight what makes this part worth - a slot in your build: materials, intended use - (duty/range/competition), and any standout features. -

-
- -
-

- Quick Specs (Placeholder) -

-
-
-
Configuration
-
- TBD (Stripped / Complete / Kit) -
-
-
-
Caliber
-
TBD from feed
-
-
-
Finish
-
TBD from merchant data
-
-
-
Weight
-
TBD (oz)
-
-
-

- Specs are placeholders for now. As we normalize more - structured attributes in the importer, this block will - auto-populate per product. -

-
-
- - {/* Right: pricing + actions */} - -
- - {/* Lower content: builder-focused helper copy (placeholder) */} -
-
-

- Why We Like This Part -

-

- Drop in a short blurb here about what makes this part - worth picking over similar options — think reliability, - track record, and value for the money. -

-
-
-

- Best For -

-

- Use this block to call out ideal use cases:{" "} - - duty rifle, home defense, range toy, competition, night - work - - , etc. -

-
-
-

- Builder Notes -

-

- Add any compatibility quirks or install tips here once - the compatibility engine is wired up — gas system length, - buffer recommendations, known fitment notes, and more. -

-
-
-

- Compatibility -

-
- - Compatibility engine coming online soon -
-

- Fitment checks, caliber validation, buffer/gas system logic, and platform rules will appear here once the engine is active. -

-
-
- - )} -
-
- ); -} \ No newline at end of file diff --git a/app/(builder)/builder/layout.tsx b/app/(builder)/builder/layout.tsx index 6280c91..d38c0ba 100644 --- a/app/(builder)/builder/layout.tsx +++ b/app/(builder)/builder/layout.tsx @@ -1,26 +1,6 @@ -// app/(builder)/layout.tsx +// app/(builder)/builder/layout.tsx import type { ReactNode } from "react"; -import { AuthProvider } from "@/context/AuthContext"; -import { BuilderNav } from "@/components/BuilderNav"; - -export const metadata = { - title: { - default: "Battl Builder", - template: "%s | Battl Builder", - }, - description: - "Battl Builder — the smarter, faster, data‑driven way to build your next firearm.", -}; - export default function BuilderLayout({ children }: { children: ReactNode }) { - return ( -
- - -
{children}
-
-
- ); -} - + return <>{children}; +} \ No newline at end of file diff --git a/app/(builder)/builder/page.tsx b/app/(builder)/builder/page.tsx index 856bc61..5471751 100644 --- a/app/(builder)/builder/page.tsx +++ b/app/(builder)/builder/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, useEffect } from "react"; +import { useMemo, useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; import { CATEGORIES } from "@/data/gunbuilderParts"; @@ -16,7 +16,7 @@ type GunbuilderProductFromApi = { platform: string; partRole: string; price: number | null; - mainImageUrl: string | null; + imageUrl: string | null; buyUrl: string | null; }; @@ -56,7 +56,7 @@ const CATEGORY_GROUPS: { description: "Everything from the serialized lower to small parts, fire control, and core controls.", categoryIds: [ - "lower", + "lower-receiver", "complete-lower", "lower-parts", "trigger", @@ -72,7 +72,7 @@ const CATEGORY_GROUPS: { description: "Barrel, upper, gas system, and the parts that keep the rifle cycling.", categoryIds: [ - "upper", + "upper-receiver", "complete-upper", "bcg", "barrel", @@ -103,12 +103,41 @@ const CATEGORY_GROUPS: { }, ]; +// ===== Build rules: complete assemblies make sub-parts unnecessary ===== +const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper"; +const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower"; + +// If a complete upper is selected, these categories are considered "included". +const UPPER_INCLUDED_CATEGORIES = new Set([ + "upper-receiver", + "bcg", + "barrel", + "gas-block", + "gas-tube", + "muzzle-device", + "suppressor", + "handguard", + "charging-handle", +]); + +const LOWER_INCLUDED_CATEGORIES = new Set([ + "lower-receiver", + "lower-parts", + "trigger", + "grip", + "safety", + "buffer", + "stock", +]); + export default function GunbuilderPage() { const searchParams = useSearchParams(); const router = useRouter(); + const [parts, setParts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const isValidPlatform = ( value: string | null ): value is (typeof PLATFORMS)[number] => @@ -119,6 +148,7 @@ export default function GunbuilderPage() { const initial = new URLSearchParams(window.location.search).get("platform"); return isValidPlatform(initial) ? initial : "AR-15"; }); + const [build, setBuild] = useState(() => { if (typeof window !== "undefined") { const stored = localStorage.getItem(STORAGE_KEY); @@ -132,9 +162,245 @@ export default function GunbuilderPage() { } return {}; }); + const [shareStatus, setShareStatus] = useState(null); const [shareUrl, setShareUrl] = useState(""); + // Guards so “platform change clears build” does NOT run on initial hydration / URL sync. + const didHydrateRef = useRef(false); + const lastPlatformRef = useRef(platform); + + // ✅ Guard to prevent infinite loops when processing ?select / ?remove + const processedActionKeyRef = useRef(""); + + // ---------- Derived ---------- + const partsByCategory: Record = useMemo(() => { + const grouped = {} as Record; + for (const category of CATEGORIES) { + grouped[category.id] = parts.filter((p) => p.categoryId === category.id); + } + return grouped; + }, [parts]); + + const selectedParts: Part[] = useMemo(() => { + const seen = new Set(); + const resolved = Object.values(build) + .map((partId) => (partId ? parts.find((p) => p.id === partId) : undefined)) + .filter(Boolean) as Part[]; + + return resolved.filter((p) => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); + }, [build, parts]); + + // Role -> Category resolver + // NOTE: defensive while backend roles/mappings evolve + const resolveCategoryId = (normalizedRole: string): CategoryId | null => { + if (normalizedRole === "upper-receiver") return "upper-receiver"; + if (normalizedRole.includes("complete-upper")) return "complete-upper"; + + if (normalizedRole === "lower-receiver") return "lower-receiver"; + if (normalizedRole.includes("complete-lower")) return "complete-lower"; + + if (normalizedRole === "upper") return "upper-receiver"; + if (normalizedRole === "lower") return "lower-receiver"; + + if (normalizedRole.includes("charging")) return "charging-handle"; + + if ( + normalizedRole.includes("handguard") || + (normalizedRole.includes("rail") && + !normalizedRole.includes("rail-accessory")) + ) + return "handguard"; + + if ( + normalizedRole.includes("bcg") || + normalizedRole.includes("bolt-carrier") + ) + return "bcg"; + + if (normalizedRole.includes("barrel")) return "barrel"; + + if ( + normalizedRole.includes("gas-block") || + normalizedRole.includes("gasblock") + ) + return "gas-block"; + + if ( + normalizedRole.includes("gas-tube") || + normalizedRole.includes("gastube") + ) + return "gas-tube"; + + if ( + normalizedRole.includes("muzzle") || + normalizedRole.includes("flash") || + normalizedRole.includes("brake") || + normalizedRole.includes("comp") + ) + return "muzzle-device"; + + if (normalizedRole.includes("suppress")) return "suppressor"; + + if ( + normalizedRole.includes("lower-parts") || + normalizedRole.includes("lpk") + ) + return "lower-parts"; + + if (normalizedRole.includes("trigger")) return "trigger"; + if (normalizedRole.includes("grip")) return "grip"; + if (normalizedRole.includes("safety")) return "safety"; + if (normalizedRole.includes("buffer")) return "buffer"; + if (normalizedRole.includes("stock")) return "stock"; + + if (normalizedRole.includes("optic") || normalizedRole.includes("scope")) + return "optic"; + if (normalizedRole.includes("sight")) return "sights"; + + if (normalizedRole.includes("mag")) return "magazine"; + if ( + normalizedRole.includes("weapon-light") || + (normalizedRole.includes("light") && !normalizedRole.includes("flight")) + ) + return "weapon-light"; + + if ( + normalizedRole.includes("foregrip") || + normalizedRole.includes("grip-vertical") + ) + return "foregrip"; + + if (normalizedRole.includes("bipod")) return "bipod"; + if (normalizedRole.includes("sling")) return "sling"; + + if ( + normalizedRole.includes("rail-accessory") || + normalizedRole.includes("rail-attachment") + ) + return "rail-accessory"; + + if (normalizedRole.includes("tool")) return "tools"; + + return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null; + }; + + const selectedByCategory: Record = useMemo( + () => + Object.fromEntries( + Object.entries(build).map(([categoryId, partId]) => [ + categoryId as CategoryId, + partId ? true : false, + ]) + ) as Record, + [build] + ); + + const totalPrice = useMemo( + () => selectedParts.reduce((sum, p) => sum + p.price, 0), + [selectedParts] + ); + + // ---------- Selection logic ---------- + const handleSelectPart = useCallback( + ( + categoryId: CategoryId, + partId: string, + opts?: { confirm?: boolean } + ) => { + const shouldConfirm = opts?.confirm !== false; + + if (shouldConfirm && typeof window !== "undefined") { + if (categoryId === COMPLETE_UPPER_CATEGORY) { + const toClear = Array.from(UPPER_INCLUDED_CATEGORIES).filter( + (cid) => !!build[cid] + ); + if (toClear.length > 0) { + const labels = toClear + .map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid) + .join(", "); + + const ok = window.confirm( + `Selecting a Complete Upper will remove your currently selected upper parts:\n\n${labels}\n\nContinue?` + ); + if (!ok) return; + } + } + + if (categoryId === COMPLETE_LOWER_CATEGORY) { + const toClear = Array.from(LOWER_INCLUDED_CATEGORIES).filter( + (cid) => !!build[cid] + ); + if (toClear.length > 0) { + const labels = toClear + .map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid) + .join(", "); + + const ok = window.confirm( + `Selecting a Complete Lower will remove your currently selected lower parts:\n\n${labels}\n\nContinue?` + ); + if (!ok) return; + } + } + + if ( + UPPER_INCLUDED_CATEGORIES.has(categoryId) && + !!build[COMPLETE_UPPER_CATEGORY] + ) { + const ok = window.confirm( + `Selecting this part will remove your selected Complete Upper. Continue?` + ); + if (!ok) return; + } + + if ( + LOWER_INCLUDED_CATEGORIES.has(categoryId) && + !!build[COMPLETE_LOWER_CATEGORY] + ) { + const ok = window.confirm( + `Selecting this part will remove your selected Complete Lower. Continue?` + ); + if (!ok) return; + } + } + + setBuild((prev) => { + const updated: BuildState = { + ...prev, + [categoryId]: partId, + }; + + if (categoryId === COMPLETE_UPPER_CATEGORY) { + for (const cid of UPPER_INCLUDED_CATEGORIES) { + delete updated[cid]; + } + } + + if (UPPER_INCLUDED_CATEGORIES.has(categoryId)) { + delete updated[COMPLETE_UPPER_CATEGORY]; + } + + if (categoryId === COMPLETE_LOWER_CATEGORY) { + for (const cid of LOWER_INCLUDED_CATEGORIES) { + delete updated[cid]; + } + } + + if (LOWER_INCLUDED_CATEGORIES.has(categoryId)) { + delete updated[COMPLETE_LOWER_CATEGORY]; + } + + return updated; + }); + }, + [build] + ); + + // ---------- Fetch products ---------- useEffect(() => { const controller = new AbortController(); @@ -143,14 +409,10 @@ export default function GunbuilderPage() { setLoading(true); setError(null); - // 1) Platform-scoped products const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent( platform )}`; - // 2) Universal products (no platform filter) - // NOTE: This assumes your backend supports /api/products with no platform param. - // If it doesn't yet, this call will fail quietly and you'll still see scoped results. const universalUrl = `${API_BASE_URL}/api/products`; const [scopedRes, universalRes] = await Promise.all([ @@ -172,7 +434,6 @@ export default function GunbuilderPage() { universalData = await universalRes.json(); } - // Normalize both lists const normalize = (data: GunbuilderProductFromApi[]): Part[] => data .map((p): Part | null => { @@ -181,11 +442,9 @@ export default function GunbuilderPage() { .toLowerCase() .replace(/_/g, "-"); - const categoryId = PART_ROLE_TO_CATEGORY[normalizedRole]; + const categoryId = resolveCategoryId(normalizedRole); if (!categoryId) return null; - // Only keep truly-universal categories from the universal feed - // so we don't accidentally mix platforms for platform-scoped parts. if ( data === universalData && !UNIVERSAL_CATEGORIES.has(categoryId) @@ -194,13 +453,15 @@ export default function GunbuilderPage() { } const buyUrl = p.buyUrl ?? undefined; + return { id: String(p.id), categoryId, name: p.name, brand: p.brand, price: p.price ?? 0, - imageUrl: p.mainImageUrl ?? undefined, + imageUrl: + ((p as any).imageUrl ?? (p as any).mainImageUrl) ?? undefined, affiliateUrl: buyUrl, url: buyUrl, notes: undefined, @@ -211,7 +472,6 @@ export default function GunbuilderPage() { const scopedParts = normalize(scopedData); const universalParts = normalize(universalData); - // Merge + de-dupe by (categoryId + id) const seen = new Set(); const merged: Part[] = []; for (const p of [...scopedParts, ...universalParts]) { @@ -231,16 +491,34 @@ export default function GunbuilderPage() { } fetchProducts(); - return () => controller.abort(); }, [platform]); + // ✅ Persist build state whenever it changes useEffect(() => { - // When the platform changes, clear ONLY platform-scoped selections. - // Keep universal accessories (optic/light/sling/etc) so the user doesn't lose them. - setBuild((prev) => { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(build)); + } catch { + // ignore + } + }, [build]); + + // When the platform changes, clear ONLY platform-scoped selections. + useEffect(() => { + const prev = lastPlatformRef.current; + lastPlatformRef.current = platform; + + if (!didHydrateRef.current) { + didHydrateRef.current = true; + return; + } + + if (prev === platform) return; + + setBuild((prevBuild) => { const next: BuildState = {}; - for (const [categoryId, partId] of Object.entries(prev)) { + for (const [categoryId, partId] of Object.entries(prevBuild)) { const cid = categoryId as CategoryId; if (UNIVERSAL_CATEGORIES.has(cid)) { next[cid] = partId; @@ -250,36 +528,64 @@ export default function GunbuilderPage() { }); }, [platform]); - // Handle URL query parameter for part selection (?select=upper:165) + // Handle URL query parameters: + // - ?select=categoryId:partId + // - ?remove=categoryId useEffect(() => { const selectParam = searchParams.get("select"); + const removeParam = searchParams.get("remove"); + const qpPlatform = searchParams.get("platform"); + + // Build a unique key for this action so we only apply it once. + const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${ + qpPlatform ?? "" + }`; + + // If no action, clear the ref and do nothing. + if (!selectParam && !removeParam) { + processedActionKeyRef.current = ""; + return; + } + + // Prevent infinite loops: if we already processed this exact actionKey, stop. + if (processedActionKeyRef.current === actionKey) return; + processedActionKeyRef.current = actionKey; + + // Apply actions if (selectParam) { - const [categoryId, partId] = selectParam.split(":"); - if (categoryId && partId && CATEGORIES.some((c) => c.id === categoryId)) { - setBuild((prev) => { - const updated = { - ...prev, - [categoryId as CategoryId]: partId, - }; - if (typeof window !== "undefined") { - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); - } - return updated; - }); + const [categoryIdRaw, partId] = selectParam.split(":"); + const requestedCategoryId = categoryIdRaw as CategoryId; - const qp = searchParams.get("platform"); - const nextPlatform = isValidPlatform(qp) ? qp : platform; - - router.replace( - `/builder?platform=${encodeURIComponent(nextPlatform)}`, - { - scroll: false, - } - ); + if (requestedCategoryId && partId) { + handleSelectPart(requestedCategoryId, partId, { confirm: false }); } } - }, [searchParams, router]); + if (removeParam) { + if (CATEGORIES.some((c) => c.id === removeParam)) { + setBuild((prev) => { + const next: BuildState = { ...prev }; + delete next[removeParam as CategoryId]; + return next; + }); + } + } + + const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform; + + // Clean URL (remove select/remove) but ONLY if needed. + const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`; + if (typeof window !== "undefined") { + const current = `${window.location.pathname}${window.location.search}`; + if (current !== nextUrl) { + router.replace(nextUrl, { scroll: false }); + } + } else { + router.replace(nextUrl, { scroll: false }); + } + }, [searchParams, router, platform, handleSelectPart]); + + // Keep platform in sync w/ URL useEffect(() => { const qp = searchParams.get("platform"); if (isValidPlatform(qp) && qp !== platform) { @@ -287,49 +593,10 @@ export default function GunbuilderPage() { } }, [searchParams, platform]); - // Persist build state to localStorage whenever it changes - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem(STORAGE_KEY, JSON.stringify(build)); - } - }, [build]); - - const partsByCategory: Record = useMemo(() => { - const grouped = {} as Record; - for (const category of CATEGORIES) { - grouped[category.id] = parts.filter((p) => p.categoryId === category.id); - } - return grouped; - }, [parts]); - - const selectedParts: Part[] = useMemo(() => { - return Object.entries(build) - .map(([categoryId, partId]) => - parts.find((p) => p.id === partId && p.categoryId === categoryId) - ) - .filter(Boolean) as Part[]; - }, [build, parts]); - - const selectedByCategory: Record = useMemo( - () => - Object.fromEntries( - Object.entries(build).map(([categoryId, partId]) => [ - categoryId as CategoryId, - partId ? true : false, - ]) - ) as Record, - [build] - ); - - const totalPrice = useMemo( - () => selectedParts.reduce((sum, p) => sum + p.price, 0), - [selectedParts] - ); - + // Build share URL whenever build changes useEffect(() => { if (typeof window === "undefined") return; - // If no parts selected, clear the share URL if (Object.keys(build).length === 0) { setShareUrl(""); return; @@ -339,28 +606,13 @@ export default function GunbuilderPage() { const payload = JSON.stringify(build); const encoded = window.btoa(payload); const origin = window.location?.origin ?? ""; - const url = `${origin}/builder/build?build=${encodeURIComponent( - encoded - )}`; + const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`; setShareUrl(url); } catch { setShareUrl(""); } }, [build]); - const handleSelectPart = (categoryId: CategoryId, partId: string) => { - setBuild((prev) => { - const updated = { - ...prev, - [categoryId]: partId, - }; - if (typeof window !== "undefined") { - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); - } - return updated; - }); - }; - // Use group ordering for the summary as well const summaryCategoryOrder: CategoryId[] = useMemo( () => @@ -443,13 +695,11 @@ export default function GunbuilderPage() { }); setShareStatus("Share dialog opened."); } catch { - // User canceled or share failed; keep it quiet but let them know setShareStatus( "Share canceled or unavailable. You can copy the link instead." ); } } else { - // Fallback to copying the link await handleCopyLink(); } }; @@ -482,10 +732,10 @@ export default function GunbuilderPage() { const next = e.target.value as (typeof PLATFORMS)[number]; setPlatform(next); - // Keep URL in sync so navigation + refresh preserve platform const qp = new URLSearchParams(searchParams.toString()); qp.set("platform", next); qp.delete("select"); + qp.delete("remove"); router.replace(`/builder?${qp.toString()}`, { scroll: false, @@ -509,7 +759,7 @@ export default function GunbuilderPage() { {/* Build summary panel */}
- {/* Left: title + totals + primary actions */} + {/* Left */}

My Build Breakdown @@ -524,10 +774,12 @@ export default function GunbuilderPage() {

- {/* Primary actions now live under the total */}
Build Summary + +
- {/* Right: Share Your Build */} + {/* Right */}
{selectedParts.length > 0 && shareUrl && (
@@ -576,8 +828,7 @@ export default function GunbuilderPage() { Share Your Build

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

@@ -652,9 +903,8 @@ export default function GunbuilderPage() { {/* Layout */}

- Work top-down through the major sections. Each row shows your - current pick for that part type, with price and a direct buy link—or - a quick way to choose a part if you haven't picked one yet. + Work top-down through the major sections. Each row shows your current pick for that part type, + with price and a direct buy link—or a quick way to choose a part if you haven't picked one yet.

{loading && ( @@ -662,8 +912,7 @@ export default function GunbuilderPage() { )} {error && !loading && (

- {error} — check that the Ballistic API is running and CORS is - configured. + {error} — check that the Ballistic API is running and CORS is configured.

)} @@ -674,9 +923,7 @@ export default function GunbuilderPage() { group.categoryIds.includes(c.id as CategoryId) ); - if (groupCategories.length === 0) { - return null; - } + if (groupCategories.length === 0) return null; return (
@@ -717,14 +964,17 @@ export default function GunbuilderPage() { + {groupCategories.map((category) => { - const categoryParts = - partsByCategory[category.id] ?? []; + const categoryParts = partsByCategory[category.id] ?? []; const selectedPartId = build[category.id]; - const selectedPart = categoryParts.find( - (p) => p.id === selectedPartId - ); + + const selectedPart = selectedPartId + ? categoryParts.find((p) => p.id === selectedPartId) ?? + parts.find((p) => p.id === selectedPartId) + : undefined; + const hasParts = categoryParts.length > 0; return ( @@ -732,7 +982,7 @@ export default function GunbuilderPage() { key={category.id} className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors" > - {/* Component / Part Type */} + {/* Component */}
{category.name} @@ -764,31 +1014,33 @@ export default function GunbuilderPage() { : "—"} - {/* Sale Price (placeholder until we wire through sale/original) */} + {/* Sale price placeholder */} - {/* TODO: wire in sale/original prices from backend */} - {selectedPart ? "—" : "—"} - - - {/* Caliber (placeholder for now) */} - - {/* TODO: wire in caliber from ProductSummaryDto when available */} — - {/* Buy / Choose */} + {/* Caliber placeholder */} + + — + + + {/* Actions */} )}
- - {/* What's New */} -
-

- What's New in Early Access -

-
    -
  • • Live parts and pricing pulled from multiple merchants.
  • -
  • - • Grouped layout for lower and upper receiver parts so you can see - at a glance which sections of your build are still missing. -
  • -
  • - • Running build total that updates automatically as you add or - swap components. -
  • -
  • - • Local build persistence so your selections stick around between - visits on this device. -
  • -
-
- - {/* Roadmap */} -
-

- What's on our roadmap -

-
    -
  • • Platform filters (AR-15, AR-9, AR-10)
  • -
  • • More part categories and furniture
  • -
  • • Richer pricing/stock sync
  • -
  • • Smarter compatibility checks between components
  • -
  • • AR9/AR10 support and more
  • -
-
); -} +} \ No newline at end of file diff --git a/app/(builder)/layout.tsx b/app/(builder)/layout.tsx index 57be1b0..9cf7fcf 100644 --- a/app/(builder)/layout.tsx +++ b/app/(builder)/layout.tsx @@ -1,11 +1,16 @@ -import { TopNav } from "@/components/TopNav"; +// app/(builder)/layout.tsx import type { ReactNode } from "react"; +import { AuthProvider } from "@/context/AuthContext"; +import { BuilderNav } from "@/components/BuilderNav"; + export default function BuilderLayout({ children }: { children: ReactNode }) { return ( -
- - {children} +
+ + +
{children}
+
); } \ No newline at end of file diff --git a/app/(builder)/builder/[categoryId]/[partId]/data.ts b/app/(builder)/parts/[category]/[partId]/data.ts similarity index 100% rename from app/(builder)/builder/[categoryId]/[partId]/data.ts rename to app/(builder)/parts/[category]/[partId]/data.ts diff --git a/app/(builder)/builder/[categoryId]/page.tsx b/app/(builder)/parts/[category]/[partId]/page.tsx similarity index 88% rename from app/(builder)/builder/[categoryId]/page.tsx rename to app/(builder)/parts/[category]/[partId]/page.tsx index e8c0b92..463ff99 100644 --- a/app/(builder)/builder/[categoryId]/page.tsx +++ b/app/(builder)/parts/[category]/[partId]/page.tsx @@ -26,18 +26,18 @@ type UiPart = Part & { inStock?: boolean; }; -const PLATFORMS = ["AR-15", "AR-10"]; +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>; // categoryId -> partId + +const STORAGE_KEY = "gunbuilder-build-state"; + // sort options type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; -// build state type + storage key (matches /gunbuilder) -type BuildState = Partial>; -const STORAGE_KEY = "gunbuilder-build-state"; - // support for url id-slug const slugify = (str: string) => str @@ -45,10 +45,13 @@ const slugify = (str: string) => .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); -const normalizeRole = (role: string) => role.trim().toLowerCase().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; + 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))); } @@ -83,8 +86,9 @@ export default function CategoryPage() { const [currentPage, setCurrentPage] = useState(1); const PAGE_SIZE = 24; - // build state for this page, synced with localStorage - const [build, setBuild] = useState(() => { + // 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(() => { if (typeof window === "undefined") return {}; try { const stored = window.localStorage.getItem(STORAGE_KEY); @@ -97,6 +101,17 @@ export default function CategoryPage() { const initialPlatform = (searchParams.get("platform") as string) || "AR-15"; const [platform, setPlatform] = useState(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] @@ -156,41 +171,22 @@ export default function CategoryPage() { return () => controller.abort(); }, [categoryId, platform, partRoles]); - // persist build to localStorage whenever it changes - useEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(build)); - } catch { - // ignore - } - }, [build]); - // 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) { - // Remove from build - setBuild((prev) => { - const updated: BuildState = { ...prev }; - delete updated[categoryId]; - return updated; - }); + // Tell the main builder to remove this category selection + qp.set("remove", String(categoryId)); + router.push(`/builder?${qp.toString()}`); return; } - // Add to build and navigate back to main builder page. - // Pass selection via URL so `/builder` can apply it. - setBuild((prev) => ({ - ...prev, - [categoryId]: partId, - })); - - const qp = new URLSearchParams(); - qp.set("platform", platform || "AR-15"); + // Tell the main builder to add/replace this category selection qp.set("select", `${categoryId}:${partId}`); - router.push(`/builder?${qp.toString()}`); }; @@ -202,6 +198,7 @@ export default function CategoryPage() { .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 }; @@ -268,9 +265,7 @@ export default function CategoryPage() { result = result.filter((p) => { const name = p.name.toLowerCase(); const brand = p.brand.toLowerCase(); - return ( - name.includes(normalizedQuery) || brand.includes(normalizedQuery) - ); + return name.includes(normalizedQuery) || brand.includes(normalizedQuery); }); } @@ -299,13 +294,11 @@ export default function CategoryPage() { } return result; - }, [parts, brandFilter, sortBy, build, categoryId, searchQuery]); + }, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceBounds, priceRange, inStockOnly]); const totalPages = useMemo( () => - filteredParts.length === 0 - ? 1 - : Math.ceil(filteredParts.length / PAGE_SIZE), + filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE), [filteredParts, PAGE_SIZE] ); @@ -318,30 +311,33 @@ export default function CategoryPage() { useEffect(() => { // whenever the category or filters change, jump back to page 1 setCurrentPage(1); - }, [ - categoryId, - brandFilter, - sortBy, - searchQuery, - priceRange, - inStockOnly, - platform, - ]); + }, [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 - ); + const end = Math.min(start + paginatedParts.length - 1, filteredParts.length); return { start, end }; }, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]); @@ -354,7 +350,7 @@ export default function CategoryPage() { Category Not Found Return to Builder @@ -371,7 +367,7 @@ export default function CategoryPage() { {/* Header */}
← Back to The Armory @@ -385,8 +381,8 @@ export default function CategoryPage() { {category.name} Parts

- Browse all available {category.name.toLowerCase()} options for - your build, pulled live from your Ballistic backend. + Browse all available {category.name.toLowerCase()} options for your + build, pulled live from your Ballistic backend.

{!loading && !error && parts.length > 0 && ( @@ -445,10 +441,7 @@ export default function CategoryPage() {
-
+
-
+
-
+
+ )} +
+ +
+
+ + Brand + + {brandFilter.length > 0 && ( + + )} +
+ {availableBrands.length === 0 ? ( +

+ No brands available yet. +

+ ) : ( +
+ {availableBrands.map((b) => { + const checked = brandFilter.includes(b); + return ( + + ); + })} +
+ )} + {brandFilter.length === 0 && availableBrands.length > 0 && ( +

+ No brands selected — showing all. +

+ )} +
+ +
+ +

+ Hides items marked out of stock by the backend. +

+
+ + )} + +
+ {!loading && !error && parts.length > 0 && ( +
+
+ Showing{" "} + + {visibleRange.start}-{visibleRange.end} + {" "} + of{" "} + + {filteredParts.length} + {" "} + matching parts +
+
+
+ +
+ 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 && ( + + )} +
+
+
+ + +
+
+ + +
+
+
+ )} + + {loading ? ( +

+ Loading {category.name.toLowerCase()}… +

+ ) : error ? ( +

+ {error} +

+ ) : filteredParts.length === 0 ? ( +

+ No parts available for this category yet. +

+ ) : viewMode === "card" ? ( +
+ {paginatedParts.map((part) => { + const isSelected = build[categoryId] === part.id; + + return ( +
+
+
+
+ {part.brand}{" "} + — {part.name} +
+
+
+ ${part.price.toFixed(2)} +
+
+
+ + View Details + + +
+
+ ); + })} +
+ ) : ( + <> +
+ Part + Brand + Price + Actions +
+ +
+ {paginatedParts.map((part) => { + const isSelected = build[categoryId] === part.id; + + return ( +
+
+
+
+ {part.name} +
+
+
+ {part.brand} +
+
+ ${part.price.toFixed(2)} +
+
+ + View Details + + +
+
+
+ ); + })} +
+ + )} + + {filteredParts.length > 0 && totalPages > 1 && ( +
+
+ Page{" "} + + {currentPage} + {" "} + of{" "} + + {totalPages} + +
+
+ + +
+
+ )} +
+
+
+ + + ); +} \ No newline at end of file diff --git a/app/admin/mapping/page.tsx b/app/admin/mapping/page.tsx index 98bf594..34384d2 100644 --- a/app/admin/mapping/page.tsx +++ b/app/admin/mapping/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; @@ -13,123 +13,58 @@ type PendingBucket = { productCount: number; }; -const PART_ROLE_OPTIONS: string[] = [ - // LOWER – receivers - "LOWER_RECEIVER_STRIPPED", - "LOWER_RECEIVER_COMPLETE", - "LOWER_RECEIVER_COMPLETE_PISTOL", - "LOWER_RECEIVER_COMPLETE_RIFLE", - "LOWER_RECEIVER_80", - "LOWER_RECEIVER_BILLET", - "LOWER_RECEIVER_FORGED", - "LOWER_RECEIVER_POLYMER", - - // LOWER – internals & controls - "LOWER_PARTS_KIT", - "LOWER_PARTS_KIT_ENHANCED", +/** + * Canonical part roles (kebab-case) — should match what the importer normalizes to. + * Keep this list tight + intentional so mappings don't create junk roles in the DB. + */ +const PART_ROLE_OPTIONS = [ + // Assemblies / receivers + "upper-receiver", + "complete-upper", + "lower-receiver", + "complete-lower", - "TRIGGER", - "TRIGGER_OTHER", - "TRIGGER_COMBAT", - "TRIGGER_MATCH", - "TRIGGER_DROP_IN", - "TRIGGER_SINGLE_STAGE", - "TRIGGER_TWO_STAGE", - "TRIGGER_GUARD", - "PISTOL_GRIP", - "PISTOL_GRIP_ERGO", - "PISTOL_GRIP_VERTICAL", - "SAFETY_SELECTOR", - "SAFETY_SELECTOR_AMBI", - "SAFETY_SELECTOR_45_DEG", - "MAG_RELEASE", - "TAKEDOWN_PINS", - "SPRINGS_PINS_MISC", - - // LOWER – buffer & stock - "BUFFER_KIT", - "BUFFER_TUBE", - "BUFFER_TUBE_MILSPEC", - "BUFFER_TUBE_COMMERCIAL", - "BUFFER_SPRING", - "BUFFER_WEIGHT", - "STOCK_ADJUSTABLE", - "STOCK_FIXED", - "STOCK_FOLDER", - "STOCK_PRECISION", - "BRACE_PISTOL", - - // UPPER – receivers & BCG - "UPPER_RECEIVER_STRIPPED", - "UPPER_RECEIVER_BILLET", - "UPPER_RECEIVER_MONOLITHIC", - "UPPER_RECEIVER_COMPLETE", - "UPPER_RECEIVER_COMPLETE_PISTOL", - "UPPER_RECEIVER_COMPLETE_RIFLE", - "BOLT_CARRIER_GROUP", - "BCG_COMPLETE", - "BCG_LIGHTWEIGHT", - "BCG_NICKEL_BORON", - - // UPPER – barrel & gas - "BARREL", - "BARREL_THREADED", - "BARREL_PENCIL", - "BARREL_MATCH", - "BARREL_HEAVY", - "BARREL_14_5_PINNED", - "GAS_BLOCK", - "GAS_BLOCK_ADJUSTABLE", - "GAS_BLOCK_LOW_PROFILE", - "GAS_TUBE_PISTOL", - "GAS_TUBE_CAR", - "GAS_TUBE_MID", - "GAS_TUBE_RIFLE", - - // UPPER – front end / muzzle - "HANDGUARD_MLOK", - "HANDGUARD_KEYMOD", - "HANDGUARD_QUAD", - "HANDGUARD_SLICK", - "MUZZLE_BRAKE", - "MUZZLE_COMPENSATOR", + // Upper sub-parts + "bcg", + "barrel", + "gas-block", + "gas-tube", + "muzzle-device", + "suppressor", + "handguard", + "charging-handle", - // 👇 generic / backup muzzle roles - "MUZZLE_DEVICE", - "MUZZLE_DEVICE_OTHER", - - "FLASH_HIDER", - "MUZZLE_DEVICE_QD_MOUNT", - "SUPPRESSOR_DIRECT_THREAD", - "SUPPRESSOR_QD", - "SUPPRESSOR_RIFLE", - "SUPPRESSOR_PISTOL", - "CHARGING_HANDLE", - "CHARGING_HANDLE_AMBI", - "CHARGING_HANDLE_GAS_BUSTER", - - // Sights / optics - "SIGHTS_BACKUP", - "SIGHTS_IRON_FIXED", - "SIGHTS_IRON_FLIP", - "SIGHTS_OFFSET", - "OPTIC_REDDOT", - "OPTIC_LPVO", - "OPTIC_HOLOGRAPHIC", - "OPTIC_PRISM", - "OPTIC_MAGNIFIER", - - // Accessories - "MAGAZINE", - "SLING", - "BIPOD", - "MOUNT_SCOPE", - "MOUNT_OFFSET", - "LIGHT_WEAPON", - "LASER_VISIBLE", - "LASER_IR", - "TOOL", - ]; + // Lower sub-parts + "lower-parts", + "trigger", + "grip", + "safety", + "buffer", + "stock", + + // Sights / optics + "sights", + "optic", + + // Accessories / gear + "magazine", + "weapon-light", + "foregrip", + "bipod", + "sling", + "rail-accessory", + "tools", +] as const; + +type PartRole = (typeof PART_ROLE_OPTIONS)[number]; + +function toLabel(role: string) { + // "complete-upper" -> "Complete Upper" + return role + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} export default function MappingAdminPage() { const [rows, setRows] = useState([]); @@ -137,17 +72,33 @@ export default function MappingAdminPage() { const [savingKey, setSavingKey] = useState(null); const [error, setError] = useState(null); + const options = useMemo( + () => + PART_ROLE_OPTIONS.map((value) => ({ + value, + label: toLabel(value), + })), + [] + ); + useEffect(() => { async function load() { try { setLoading(true); setError(null); + const res = await fetch( - `${API_BASE_URL}/api/admin/mapping/pending-buckets` + `${API_BASE_URL}/api/admin/mapping/pending-buckets`, + { headers: { Accept: "application/json" } } ); + if (!res.ok) { - throw new Error(`Failed to load pending buckets (${res.status})`); + const text = await res.text().catch(() => ""); + throw new Error( + `Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}` + ); } + const data: PendingBucket[] = await res.json(); setRows(data); } catch (e: any) { @@ -162,9 +113,14 @@ export default function MappingAdminPage() { }, []); const handleChangeRole = (idx: number, value: string) => { + // Always store normalized kebab-case (defensive) + const normalized = value + ? value.trim().toLowerCase().replace(/_/g, "-") + : ""; + setRows((prev) => { const next = [...prev]; - next[idx] = { ...next[idx], mappedPartRole: value || null }; + next[idx] = { ...next[idx], mappedPartRole: normalized || null }; return next; }); }; @@ -172,6 +128,20 @@ export default function MappingAdminPage() { const handleSave = async (row: PendingBucket) => { if (!row.mappedPartRole) return; + const mapped = row.mappedPartRole + .trim() + .toLowerCase() + .replace(/_/g, "-"); + + // Basic guard: prevent saving random roles from stale data + const allowed = new Set(PART_ROLE_OPTIONS as readonly string[]); + if (!allowed.has(mapped)) { + setError( + `Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.` + ); + return; + } + const key = `${row.merchantId}-${row.rawCategoryKey}`; try { setSavingKey(key); @@ -183,12 +153,15 @@ export default function MappingAdminPage() { body: JSON.stringify({ merchantId: row.merchantId, rawCategoryKey: row.rawCategoryKey, - mappedPartRole: row.mappedPartRole, + mappedPartRole: mapped, }), }); if (!res.ok) { - throw new Error(`Failed to save mapping (${res.status})`); + const text = await res.text().catch(() => ""); + throw new Error( + `Failed to save mapping (${res.status})${text ? `: ${text}` : ""}` + ); } // After save, remove this bucket from the list @@ -241,8 +214,8 @@ export default function MappingAdminPage() { Pending Buckets - {rows.length} buckets • {rows.reduce((s, r) => s + r.productCount, 0)}{" "} - products + {rows.length} buckets •{" "} + {rows.reduce((s, r) => s + r.productCount, 0)} products @@ -283,7 +256,9 @@ export default function MappingAdminPage() { {row.rawCategoryKey} - {row.productCount} + + {row.productCount} + diff --git a/app/admin/merchants/page.tsx b/app/admin/merchants/page.tsx index c4239c4..24b2027 100644 --- a/app/admin/merchants/page.tsx +++ b/app/admin/merchants/page.tsx @@ -34,52 +34,50 @@ export default function MerchantsAdminPage() { // --- load merchants --- useEffect(() => { - let cancelled = false; + const controller = new AbortController(); async function loadMerchants() { try { - console.log("Loading merchants from:", `${API_BASE_URL}/admin/merchants`); - - const res = await fetch(`${API_BASE_URL}/admin/merchants`, { + setLoading(true); + setError(null); + + const url = `${API_BASE_URL}/api/admin/merchants`; + console.log("Loading merchants from:", url); + + const res = await fetch(url, { method: "GET", - headers: { - Accept: "application/json", - }, + headers: { Accept: "application/json" }, + signal: controller.signal, }); - - console.log("Merchants response status:", res.status); - + if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`HTTP ${res.status} ${res.statusText} – ${text}`); } - + const data: MerchantAdminDto[] = await res.json(); setMerchants(data); - setError(null); } catch (err: any) { + if (err?.name === "AbortError") return; console.error("Error loading merchants", err); - setError(err.message ?? "Failed to load merchants"); + setError(err?.message ?? "Failed to load merchants"); } finally { setLoading(false); } } - - loadMerchants(); - return () => { - cancelled = true; - }; + loadMerchants(); + return () => controller.abort(); }, []); // --- local field editing --- const updateMerchantField = ( id: number, field: K, - value: MerchantAdminDto[K], + value: MerchantAdminDto[K] ) => { setMerchants((prev) => - prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)), + prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)) ); }; @@ -93,7 +91,7 @@ export default function MerchantsAdminPage() { setError(null); setBanner(null); - const res = await fetch(`${API_BASE_URL}/admin/merchants/${id}`, { + const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -108,20 +106,16 @@ export default function MerchantsAdminPage() { if (!res.ok) { const text = await res.text().catch(() => ""); console.error("Merchant save failed", res.status, text); - throw new Error( - `Save failed (${res.status})${text ? `: ${text}` : ""}`, - ); + throw new Error(`Save failed (${res.status})${text ? `: ${text}` : ""}`); } const updated: MerchantAdminDto = await res.json(); - setMerchants((prev) => - prev.map((m) => (m.id === id ? updated : m)), - ); + setMerchants((prev) => prev.map((m) => (m.id === id ? updated : m))); setBanner("Merchant saved successfully."); } catch (e: any) { console.error("Error saving merchant", e); - setError(e.message ?? "Failed to save merchant"); + setError(e?.message ?? "Failed to save merchant"); } finally { setSavingId(null); setTimeout(() => setBanner(null), 3000); @@ -135,29 +129,27 @@ export default function MerchantsAdminPage() { setError(null); setBanner(null); - const res = await fetch(`${API_BASE_URL}/admin/imports/${id}`, { + const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, { method: "POST", }); if (!res.ok) { const text = await res.text().catch(() => ""); console.error("Full import failed", res.status, text); - throw new Error( - `Import failed (${res.status})${text ? `: ${text}` : ""}`, - ); + throw new Error(`Import failed (${res.status})${text ? `: ${text}` : ""}`); } setBanner("Full import started successfully."); } catch (e: any) { console.error("Error starting full import", e); - setError(e.message ?? "Failed to start full import"); + setError(e?.message ?? "Failed to start full import"); } finally { setImportingId(null); setTimeout(() => setBanner(null), 3000); } }; - // --- run offer sync (matches /{merchantId}/offers-only) --- + // --- run offer sync --- const handleRunOfferSync = async (id: number) => { try { setOfferSyncId(id); @@ -165,24 +157,22 @@ export default function MerchantsAdminPage() { setBanner(null); const res = await fetch( - `${API_BASE_URL}/admin/imports/${id}/offers-only`, - { - method: "POST", - }, + `${API_BASE_URL}/api/admin/imports/${id}/offers-only`, + { method: "POST" } ); if (!res.ok) { const text = await res.text().catch(() => ""); console.error("Offer sync failed", res.status, text); throw new Error( - `Offer sync failed (${res.status})${text ? `: ${text}` : ""}`, + `Offer sync failed (${res.status})${text ? `: ${text}` : ""}` ); } setBanner("Offer sync started successfully."); } catch (e: any) { console.error("Error starting offer sync", e); - setError(e.message ?? "Failed to start offer sync"); + setError(e?.message ?? "Failed to start offer sync"); } finally { setOfferSyncId(null); setTimeout(() => setBanner(null), 3000); @@ -202,9 +192,9 @@ export default function MerchantsAdminPage() { Merchant Feeds Admin

- Manage your AvantLink merchants, product feed URLs, and offer - sync settings. Use this to onboard new merchants and keep feeds - fresh without touching SQL. + Manage your AvantLink merchants, product feed URLs, and offer sync + settings. Use this to onboard new merchants and keep feeds fresh + without touching SQL.

@@ -244,6 +234,7 @@ export default function MerchantsAdminPage() { Actions + {merchants.map((m) => ( @@ -257,71 +248,62 @@ export default function MerchantsAdminPage() { className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" /> + - updateMerchantField( - m.id, - "avantlinkMid", - e.target.value, - ) + updateMerchantField(m.id, "avantlinkMid", e.target.value) } className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" /> + - updateMerchantField( - m.id, - "feedUrl", - e.target.value, - ) + updateMerchantField(m.id, "feedUrl", e.target.value) } className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" /> + - updateMerchantField( - m.id, - "offerFeedUrl", - e.target.value || null, - ) + updateMerchantField(m.id, "offerFeedUrl", e.target.value || null) } className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" /> + + {formatDate(m.lastFullImportAt)} + {formatDate(m.lastOfferSyncAt)} +
+ +
diff --git a/components/BuilderNav.tsx b/components/BuilderNav.tsx index cbc41ef..3b874ce 100644 --- a/components/BuilderNav.tsx +++ b/components/BuilderNav.tsx @@ -24,7 +24,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) { const currentCategory = activeCategoryId ?? searchParams.get("category") ?? undefined; - const baseHref = "/builder"; + const baseHref = "/parts"; const renderDropdown = (label: string, items: Category[]) => { if (!items.length) return null; @@ -71,10 +71,10 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) { return (
{/* ← aligns with topnav */} -