diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..1c2fda5 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..2c0ecc2 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..4e072b7 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..e458f8c --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..bd4a159 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..7d6cf34 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/shadow-gunbuilder-ai-proto.iml b/.idea/shadow-gunbuilder-ai-proto.iml new file mode 100644 index 0000000..18ec59d --- /dev/null +++ b/.idea/shadow-gunbuilder-ai-proto.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..83c12bd --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..c8397c9 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/admin/categories/page.tsx b/app/admin/categories/page.tsx new file mode 100644 index 0000000..f394cf6 --- /dev/null +++ b/app/admin/categories/page.tsx @@ -0,0 +1,125 @@ +// app/admin/categories/page.tsx +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "@/context/AuthContext"; +import { + fetchAdminCategories, + type AdminCategory, +} from "@/lib/api/adminCategoryMappings"; + +export default function AdminCategoriesPage() { + const { token } = useAuth(); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) return; + + const load = async () => { + try { + setLoading(true); + const cats = await fetchAdminCategories(token); + setCategories(cats); + } catch (e: any) { + console.error(e); + setError(e?.message ?? "Failed to load categories"); + } finally { + setLoading(false); + } + }; + + load(); + }, [token]); + + if (!token) { + return ( +
+ You must be logged in to view this page. +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Canonical categories +

+

+ These are your standardized builder categories and their groupings. +

+
+ {/* Placeholder for future actions (add/edit) */} + {/* */} +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Table */} + {loading ? ( +
Loading…
+ ) : ( +
+ + + + + + + + + + + + {categories.map((cat) => ( + + + + + + + + ))} + + {categories.length === 0 && ( + + + + )} + +
GroupNameSlugSort orderDescription
+ {cat.groupName ?? "—"} + {cat.name} + {cat.slug} + + {cat.sortOrder ?? "—"} + + {cat.description || ( + No description + )} +
+ No categories found. Seed the part_categories{" "} + table to get started. +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/admin/merchant-category-mappings/page.tsx b/app/admin/merchant-category-mappings/page.tsx new file mode 100644 index 0000000..567deee --- /dev/null +++ b/app/admin/merchant-category-mappings/page.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useApi } from "@/lib/useApi"; + +type Merchant = { + id: number; + name: string; +}; + +type CategoryMapping = { + id: number; + merchantId: number; + merchantName: string; + rawCategoryPath: string; + partCategoryId: number | null; + partCategoryName?: string | null; +}; + +// Matches the Spring MerchantCategoryMappingDto record +type MerchantCategoryMappingDto = { + id: number; + merchantId: number; + merchantName: string; + rawCategoryPath: string; + partCategoryId: number | null; + partCategoryName?: string | null; +}; + +type AdminCategory = { + id: number; + name: string; + groupName?: string | null; +}; + +export default function MerchantCategoryMappingPage() { + const { get, post } = useApi(); + + const [merchants, setMerchants] = useState([]); + const [selectedMerchantId, setSelectedMerchantId] = useState( + null + ); + const [mappings, setMappings] = useState([]); + const [loadingMerchants, setLoadingMerchants] = useState(false); + const [loadingMappings, setLoadingMappings] = useState(false); + const [savingId, setSavingId] = useState(null); + const [error, setError] = useState(null); + const [categories, setCategories] = useState([]); + const [loadingCategories, setLoadingCategories] = useState(false); + const [dirty, setDirty] = useState>({}); + + // 1) Load merchants for the dropdown + useEffect(() => { + setLoadingMerchants(true); + setError(null); + + get("/api/admin/category-mappings/merchants") + .then((data) => { + setMerchants(data); + if (data.length > 0 && selectedMerchantId == null) { + setSelectedMerchantId(data[0].id); + } + }) + .catch((err: any) => { + console.error("Failed to load merchants", err); + setError( + err instanceof Error ? err.message : "Failed to load merchants" + ); + }) + .finally(() => setLoadingMerchants(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Load global (canonical) part categories for the dropdown + // Load global (canonical) part categories for the dropdown + useEffect(() => { + setLoadingCategories(true); + setError(null); + + get("/api/admin/categories") + .then((data) => { + setCategories(data); + }) + .catch((err: any) => { + console.error("Failed to load categories", err); + setError( + err instanceof Error ? err.message : "Failed to load categories" + ); + }) + .finally(() => setLoadingCategories(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // 2) Load mappings when merchant changes (THIS sends merchantId) + useEffect(() => { + if (selectedMerchantId == null) return; + + setLoadingMappings(true); + setError(null); + + get( + `/api/admin/category-mappings?merchantId=${selectedMerchantId}` + ) + .then((data) => { + setMappings(data); + }) + .catch((err: any) => { + console.error("Failed to load category mappings", err); + setError( + err instanceof Error + ? err.message + : "Failed to load category mappings" + ); + }) + .finally(() => setLoadingMappings(false)); + }, [selectedMerchantId]); + + // 3) Save a single mapping row (adjust endpoint if yours differs) + const handleSaveMapping = async ( + mappingId: number, + updated: { partCategoryId: number | null } + ) => { + setSavingId(mappingId); + setError(null); + try { + const updatedMapping = await post( + `/api/admin/category-mappings/${mappingId}`, + { + partCategoryId: updated.partCategoryId, + } + ); + + setMappings((prev) => + prev.map((m) => + m.id === mappingId + ? { + ...m, + // map Java DTO fields back into your UI model + merchantId: updatedMapping.merchantId, + merchantName: updatedMapping.merchantName, + rawCategoryPath: updatedMapping.rawCategoryPath, + partCategoryId: updatedMapping.partCategoryId, + partCategoryName: updatedMapping.partCategoryName, + } + : m + ) + ); + } catch (err: any) { + console.error("Failed to save mapping", err); + setError(err instanceof Error ? err.message : "Failed to save mapping"); + } finally { + setSavingId(null); + } + }; + + const handleSaveAll = async () => { + if (Object.keys(dirty).length === 0) return; + + // special flag to indicate a global save is in progress + setSavingId(-1); + setError(null); + + try { + const dirtyRows = Object.values(dirty); + + const updatedMappings = await Promise.all( + dirtyRows.map((row) => + post( + `/api/admin/category-mappings/${row.id}`, + { + partCategoryId: row.partCategoryId ?? null, + } + ) + ) + ); + + setMappings((prev) => + prev.map((m) => { + const updated = updatedMappings.find((u) => u.id === m.id); + if (!updated) return m; + + return { + ...m, + merchantId: updated.merchantId, + merchantName: updated.merchantName, + rawCategoryPath: updated.rawCategoryPath, + partCategoryId: updated.partCategoryId, + partCategoryName: updated.partCategoryName, + }; + }) + ); + + // Clear dirty state after successful batch save + setDirty({}); + } catch (err: any) { + console.error("Failed to save all mappings", err); + setError( + err instanceof Error + ? err.message + : "Failed to save all mappings" + ); + } finally { + setSavingId(null); + } + }; + + return ( +
+
+

+ Merchant Category Mapping +

+ +
+ Merchant: + +
+
+ + {error && ( +
+ {error} +
+ )} + + {Object.keys(dirty).length > 0 && ( +
+ +
+ )} + +
+
+ Category Mappings +
+ + {loadingMappings ? ( +
Loading mappings…
+ ) : mappings.length === 0 ? ( +
+ No mappings found for this merchant. +
+ ) : ( + + + + + + + + + + {mappings.map((m) => ( + + + + + + ))} + +
+ Merchant Category + + Global Category + + Actions +
+ {m.rawCategoryPath} + + {loadingCategories ? ( + + Loading categories… + + ) : categories.length === 0 ? ( + + No categories + + ) : ( + + )} + + {m.partCategoryName && ( +
+ Current: {m.partCategoryName} +
+ )} +
+ +
+ )} +
+
+ ); +} diff --git a/app/gunbuilder/admin/merchants/page.tsx b/app/admin/merchants/page.tsx similarity index 100% rename from app/gunbuilder/admin/merchants/page.tsx rename to app/admin/merchants/page.tsx diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..6fb7bee --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import Link from "next/link"; +import { useAuth } from "@/context/AuthContext"; + +export default function AdminHomePage() { + const { token } = useAuth(); + + if (!token) { + return ( +
+ You must be logged in to view this page. +
+ ); + } + + const cards = [ + { + title: "Canonical categories", + href: "admin/categories", + description: + "Manage the core builder categories and their group/grouping + sort order.", + }, + { + title: "Part role mappings", + href: "/admin/part-role-mappings", + description: + "Advanced: map logical builder part roles to canonical categories per platform.", + }, + { + title: "Merchant category mappings", + href: "/admin/merchant-category-mappings", + description: + "Map raw merchant feed categories to your canonical categories.", + }, + ]; + + return ( +
+
+

+ Admin +

+

+ Internal tools to keep your builder data clean and consistent. +

+
+ +
+ {cards.map((card) => ( + +

+ {card.title} +

+

+ {card.description} +

+ + ))} +
+
+ ); +} \ No newline at end of file diff --git a/app/admin/part-role-mappings/page.tsx b/app/admin/part-role-mappings/page.tsx new file mode 100644 index 0000000..294bf48 --- /dev/null +++ b/app/admin/part-role-mappings/page.tsx @@ -0,0 +1,228 @@ +// app/admin/category-mappings/page.tsx +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "@/context/AuthContext"; +import { + fetchAdminCategories, + fetchPartRoleMappings, + createPartRoleMapping, + updatePartRoleMapping, + deletePartRoleMapping, + type AdminCategory, + type AdminPartRoleMapping, +} from "@/lib/api/adminCategoryMappings"; + +export default function AdminCategoryMappingsPage() { + const { token } = useAuth(); + const [categories, setCategories] = useState([]); + const [mappings, setMappings] = useState([]); + const [platform, setPlatform] = useState("AR-15"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) return; + + const load = async () => { + try { + setLoading(true); + const [cats, maps] = await Promise.all([ + fetchAdminCategories(token), + fetchPartRoleMappings(token, platform), + ]); + setCategories(cats); + setMappings(maps); + } catch (e: any) { + console.error(e); + setError(e?.message ?? "Failed to load mappings"); + } finally { + setLoading(false); + } + }; + + load(); + }, [token, platform]); + + const handleCreate = async () => { + if (!token) return; + const defaultCategory = categories[0]; + if (!defaultCategory) return; + + try { + setSaving(true); + const created = await createPartRoleMapping(token, { + platform, + partRole: "NEW_PART_ROLE", + categorySlug: defaultCategory.slug, + }); + setMappings((prev) => [...prev, created]); + } catch (e: any) { + console.error(e); + setError(e?.message ?? "Failed to create mapping"); + } finally { + setSaving(false); + } + }; + + const handleUpdate = async (row: AdminPartRoleMapping, updates: Partial) => { + if (!token) return; + try { + setSaving(true); + const updated = await updatePartRoleMapping(token, row.id, { + platform: updates.platform ?? row.platform, + partRole: updates.partRole ?? row.partRole, + categorySlug: updates.categorySlug ?? row.categorySlug, + notes: updates.notes ?? row.notes ?? undefined, + }); + setMappings((prev) => prev.map((m) => (m.id === row.id ? updated : m))); + } catch (e: any) { + console.error(e); + setError(e?.message ?? "Failed to update mapping"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (row: AdminPartRoleMapping) => { + if (!token) return; + if (!confirm(`Delete mapping for ${row.partRole}?`)) return; + + try { + setSaving(true); + await deletePartRoleMapping(token, row.id); + setMappings((prev) => prev.filter((m) => m.id !== row.id)); + } catch (e: any) { + console.error(e); + setError(e?.message ?? "Failed to delete mapping"); + } finally { + setSaving(false); + } + }; + + if (!token) { + return
You must be logged in to view this page.
; + } + + return ( +
+
+

+ Part role → category mappings +

+

+ FOR LATER! Advanced mapping: defines which canonical category each builder part role + uses for a given platform. +

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading…
+ ) : ( +
+ + + + + + + + + + + + + {mappings.map((row) => ( + + + + + + + + + ))} + + {mappings.length === 0 && ( + + + + )} + +
PlatformPart roleCategoryGroupNotesActions
{row.platform} + { + if (e.target.value !== row.partRole) { + handleUpdate(row, { partRole: e.target.value }); + } + }} + /> + + + {row.groupName} + { + if (e.target.value !== (row.notes ?? "")) { + handleUpdate(row, { notes: e.target.value }); + } + }} + /> + + +
+ No mappings yet for {platform}. Click “Add mapping” to get started. +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/gunbuilder/[categoryId]/[partId]/data.ts b/app/builder/[categoryId]/[partId]/data.ts similarity index 100% rename from app/gunbuilder/[categoryId]/[partId]/data.ts rename to app/builder/[categoryId]/[partId]/data.ts diff --git a/app/builder/[categoryId]/[partId]/page.tsx b/app/builder/[categoryId]/[partId]/page.tsx new file mode 100644 index 0000000..e00ea16 --- /dev/null +++ b/app/builder/[categoryId]/[partId]/page.tsx @@ -0,0 +1,436 @@ +"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; + mainImageUrl: 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; + const partId = params.partId as string; // this is the UUID we passed in the link + + 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?platform=AR-15`; + 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(); + const found = data.find((p) => p.id === partId) ?? null; + + if (!found) { + setError("Product not found"); + } + + setProduct(found); + } 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 Gunbuilder + +
+
+ ); + } + + 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.mainImageUrl && ( +
+ {/* 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/[categoryId]/page.tsx b/app/builder/[categoryId]/page.tsx new file mode 100644 index 0000000..aeea750 --- /dev/null +++ b/app/builder/[categoryId]/page.tsx @@ -0,0 +1,806 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { CATEGORIES } from "@/data/gunbuilderParts"; +import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings"; +import type { CategoryId, Part } from "@/types/gunbuilder"; + +type ViewMode = "card" | "list"; + +type GunbuilderProductFromApi = { + id: string; + name: string; + brand: string; + platform: string; + partRole: string; + price: number | null; + mainImageUrl: string | null; + buyUrl: string | null; + // optional stock flag if/when backend sends it + inStock?: boolean | null; +}; + +type UiPart = Part & { + // local-only stock flag for filtering + inStock?: boolean; +}; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + + +// 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"; + +export default function CategoryPage() { + const params = useParams(); + const categoryId = params.categoryId as CategoryId; + const router = useRouter(); + + const [viewMode, setViewMode] = useState("list"); + const [parts, setParts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // filter + sort state + const [brandFilter, setBrandFilter] = useState([]); + const [sortBy, setSortBy] = useState("relevance"); + const [searchQuery, setSearchQuery] = useState(""); + const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({ + min: null, + max: null, + }); + const [inStockOnly, setInStockOnly] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const PAGE_SIZE = 24; + + // build state for this page, synced with localStorage + const [build, setBuild] = useState(() => { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored ? (JSON.parse(stored) as BuildState) : {}; + } catch { + return {}; + } + }); + + const category = useMemo( + () => CATEGORIES.find((c) => c.id === categoryId), + [categoryId], + ); + + useEffect(() => { + if (!categoryId) return; + + const controller = new AbortController(); + + async function fetchCategoryParts() { + try { + setLoading(true); + setError(null); + + // FIX: determine which backend partRoles map to this category + const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? []; + + const search = new URLSearchParams(); + search.set("platform", "AR-15"); + roles.forEach((r) => search.append("partRoles", r)); + + const url = `${API_BASE_URL}/api/products/gunbuilder${ + roles.length ? `?${search.toString()}` : "" + }`; + + const res = await fetch(url, { signal: controller.signal }); + + if (!res.ok) { + throw new Error(`Failed to load products (${res.status})`); + } + + const data: GunbuilderProductFromApi[] = await res.json(); + + const normalized: UiPart[] = data.map((p) => ({ + id: p.id, + categoryId, + name: p.name, + brand: p.brand, + price: p.price ?? 0, + imageUrl: p.mainImageUrl ?? undefined, + url: p.buyUrl ?? undefined, + notes: undefined, + inStock: p.inStock ?? true, + })); + + setParts(normalized); + } catch (err: any) { + if (err.name === "AbortError") return; + setError(err.message ?? "Failed to load products"); + } finally { + setLoading(false); + } + } + + fetchCategoryParts(); + + return () => controller.abort(); + }, [categoryId]); + + // 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]); + + useEffect(() => { + // whenever the category or filters change, jump back to page 1 + setCurrentPage(1); + }, [categoryId, brandFilter, sortBy, searchQuery]); + + // handler to toggle Add / Remove for this category + const handleTogglePart = (categoryId: CategoryId, partId: string) => { + const isSelected = build[categoryId] === partId; + + if (isSelected) { + // Remove from build + setBuild((prev) => { + const updated: BuildState = { ...prev }; + delete updated[categoryId]; + return updated; + }); + } else { + // Add to build and navigate back to main gunbuilder page + setBuild((prev) => ({ + ...prev, + [categoryId]: partId, + })); + router.push("/gunbuilder"); + } + }; + + // available brands for filter + const availableBrands = useMemo( + () => + Array.from(new Set(parts.map((p) => p.brand))) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)), + [parts], + ); + const priceBounds = useMemo(() => { + if (parts.length === 0) { + return { min: null as number | null, max: null as number | null }; + } + + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const p of parts) { + const price = p.price ?? 0; + if (price < min) min = price; + if (price > max) max = price; + } + + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return { min: null as number | null, max: null as number | null }; + } + + return { min, max }; + }, [parts]); + + // initialize / clamp priceRange whenever bounds change + useEffect(() => { + if (priceBounds.min == null || priceBounds.max == null) return; + + setPriceRange((prev) => { + const min = prev.min ?? priceBounds.min!; + const max = prev.max ?? priceBounds.max!; + return { + min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)), + max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)), + }; + }); + }, [priceBounds.min, priceBounds.max]); + + const filteredParts = useMemo(() => { + let result = [...parts]; + + // Price range filter + const effectiveMin = + priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null); + const effectiveMax = + priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null); + + if (effectiveMin != null && effectiveMax != null) { + result = result.filter((p) => { + const price = p.price ?? 0; + return price >= effectiveMin && price <= effectiveMax; + }); + } + + // Brand filter + if (brandFilter.length > 0) { + result = result.filter((p) => brandFilter.includes(p.brand)); + } + + // In-stock filter + if (inStockOnly) { + result = result.filter((p) => p.inStock ?? true); + } + + const normalizedQuery = searchQuery.trim().toLowerCase(); + if (normalizedQuery) { + result = result.filter((p) => { + const name = p.name.toLowerCase(); + const brand = p.brand.toLowerCase(); + return ( + name.includes(normalizedQuery) || + brand.includes(normalizedQuery) + ); + }); + } + + switch (sortBy) { + case "price-asc": + result.sort((a, b) => a.price - b.price); + break; + case "price-desc": + result.sort((a, b) => b.price - a.price); + break; + case "brand-asc": + result.sort((a, b) => a.brand.localeCompare(b.brand)); + break; + case "relevance": + default: + // leave in backend order + break; + } + + // Pin the currently selected part (for this category) to the top, if any + const selectedId = build[categoryId]; + if (selectedId) { + const selected = result.filter((p) => p.id === selectedId); + const others = result.filter((p) => p.id !== selectedId); + result = [...selected, ...others]; + } + + return result; + }, [parts, brandFilter, sortBy, build, categoryId, searchQuery]); + + const totalPages = useMemo( + () => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)), + [filteredParts, PAGE_SIZE] + ); + + const paginatedParts = useMemo(() => { + if (filteredParts.length === 0) return []; + const startIndex = (currentPage - 1) * PAGE_SIZE; + return filteredParts.slice(startIndex, startIndex + PAGE_SIZE); + }, [filteredParts, currentPage, PAGE_SIZE]); + + useEffect(() => { + // whenever the category or filters change, jump back to page 1 + setCurrentPage(1); + }, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]); + + const visibleRange = useMemo(() => { + if (filteredParts.length === 0) { + return { start: 0, end: 0 }; + } + const start = (currentPage - 1) * PAGE_SIZE + 1; + const end = Math.min(start + paginatedParts.length - 1, filteredParts.length); + return { start, end }; + }, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]); + + if (!category) { + return ( +
+
+
+

+ Category Not Found +

+ + Return to Gunbuilder + +
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + ← Back to The Armory + +
+
+

+ Shadow Standard +

+

+ {category.name} Parts +

+

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

+
+ {!loading && !error && parts.length > 0 && ( +
+ + +
+ )} +
+
+ + {/* Parts Grid/List */} +
+
+ {/* Left sidebar: filters */} + {!loading && !error && parts.length > 0 && ( + + )} + + {/* Right side: toolbar + results */} +
+ {/* Top toolbar: count + search + sort */} + {!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 && ( + + )} +
+
+
+ + +
+
+
+ )} + + {/* Results / states */} + {loading ? ( +

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

+ ) : error ? ( +

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

+ ) : 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.notes && ( +

+ {part.notes} +

+ )} +
+
+ ${part.price.toFixed(2)} +
+
+
+ + View Details + + +
+
+ ); + })} +
+ ) : ( + <> + {/* Header row for list view */} +
+ Part + Brand + Price + Actions +
+ +
+ {paginatedParts.map((part) => { + const isSelected = build[categoryId] === part.id; + + return ( +
+
+
+
+ {part.name} +
+ {part.notes && ( +

+ {part.notes} +

+ )} +
+
+ {part.brand} +
+
+ ${part.price.toFixed(2)} +
+
+ + View Details + + +
+
+
+ ); + })} +
+ + )} + + {/* Pagination controls */} + {filteredParts.length > 0 && totalPages > 1 && ( +
+
+ Page{" "} + + {currentPage} + {" "} + of{" "} + + {totalPages} + +
+
+ + +
+
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/gunbuilder/build/page.tsx b/app/builder/build/page.tsx similarity index 100% rename from app/gunbuilder/build/page.tsx rename to app/builder/build/page.tsx diff --git a/app/gunbuilder/page.tsx b/app/builder/page.tsx similarity index 97% rename from app/gunbuilder/page.tsx rename to app/builder/page.tsx index 6c071cb..f070fb9 100644 --- a/app/gunbuilder/page.tsx +++ b/app/builder/page.tsx @@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from "next/navigation"; import { CATEGORIES } from "@/data/gunbuilderParts"; import type { CategoryId, Part } from "@/types/gunbuilder"; import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings"; +import { Pencil, Link2 } from "lucide-react"; type GunbuilderProductFromApi = { id: number; // backend numeric id @@ -598,17 +599,21 @@ export default function GunbuilderPage() { <> - Change Part + - Buy + ) : hasParts ? ( diff --git a/app/builds/page.tsx b/app/builds/page.tsx new file mode 100644 index 0000000..6687e87 --- /dev/null +++ b/app/builds/page.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; + +type BuildClass = "Rifle" | "Pistol" | "NFA"; +type Caliber = "5.56" | "7.62" | "9mm" | ".300 BLK" | "12ga"; + +type BuildCard = { + id: string; + title: string; + slug: string; + creator: string; + caliber: Caliber; + buildClass: BuildClass; + price: number; + votes: number; + tags: string[]; +}; + +const DUMMY_BUILDS: BuildCard[] = [ + { + id: "1", + title: "Duty-Grade 12.5\" AR-15", + slug: "duty-12-5-ar15", + creator: "quietpro_01", + caliber: "5.56", + buildClass: "Rifle", + price: 2450, + votes: 128, + tags: ["Duty", "NV-Ready", "LPVO"], + }, + { + id: "2", + title: "Home Defense PCC", + slug: "home-defense-pcc", + creator: "nerdgunner", + caliber: "9mm", + buildClass: "Pistol", + price: 1650, + votes: 74, + tags: ["Home Defense", "Red Dot", "Suppressor-Ready"], + }, + { + id: "3", + title: "Short King .300 BLK SBR", + slug: "short-king-300blk-sbr", + creator: "subsonic_six", + caliber: ".300 BLK", + buildClass: "NFA", + price: 3125, + votes: 201, + tags: ["SBR", "Suppressed", "Night Work"], + }, + { + id: "4", + title: "Budget 16\" Training Rifle", + slug: "budget-16-training-rifle", + creator: "range_rat", + caliber: "5.56", + buildClass: "Rifle", + price: 975, + votes: 53, + tags: ["Budget", "Trainer", "Recce-ish"], + }, +]; + +const CALIBER_FILTERS: (Caliber | "all")[] = [ + "all", + "5.56", + "7.62", + "9mm", + ".300 BLK", + "12ga", +]; + +const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"]; + +const PRICE_FILTERS = [ + { id: "all", label: "All", min: 0, max: Infinity }, + { id: "sub1k", label: "< $1k", min: 0, max: 1000 }, + { id: "1to2k", label: "$1k–$2k", min: 1000, max: 2000 }, + { id: "2to3k", label: "$2k–$3k", min: 2000, max: 3000 }, + { id: "3kplus", label: "$3k+", min: 3000, max: Infinity }, +]; + +export default function BuildsPage() { + const [caliberFilter, setCaliberFilter] = useState("all"); + const [classFilter, setClassFilter] = useState("all"); + const [priceFilterId, setPriceFilterId] = useState("all"); + const [votes, setVotes] = useState>( + Object.fromEntries(DUMMY_BUILDS.map((b) => [b.id, b.votes])), + ); + + const activePriceFilter = PRICE_FILTERS.find( + (p) => p.id === priceFilterId, + ) ?? PRICE_FILTERS[0]; + + const filteredBuilds = useMemo(() => { + return DUMMY_BUILDS.filter((build) => { + const matchesCaliber = + caliberFilter === "all" || build.caliber === caliberFilter; + + const matchesClass = + classFilter === "all" || build.buildClass === classFilter; + + const matchesPrice = + build.price >= activePriceFilter.min && + build.price < activePriceFilter.max; + + return matchesCaliber && matchesClass && matchesPrice; + }); + }, [caliberFilter, classFilter, activePriceFilter]); + + const handleVote = (id: string, delta: 1 | -1) => { + setVotes((prev) => ({ + ...prev, + [id]: (prev[id] ?? 0) + delta, + })); + }; + + return ( +
+
+ {/* Header */} +
+
+

+ Battl Builder · Build Book +

+

+ Community Builds +

+

+ Browse community rifle builds, vote them up or down, and steal + good ideas without shame. This is placeholder content for now — + real accounts, comments, and saved builds will wire in later. +

+
+
+ + Open Builder + + +
+
+ + {/* Filters */} +
+
+
+

+ Filter Builds +

+

+ Filter by caliber, platform class, and ballpark price range. + All logic is client-side placeholder until the real feed is wired + into the backend. +

+
+
+ Showing{" "} + + {filteredBuilds.length} + {" "} + of{" "} + + {DUMMY_BUILDS.length} + {" "} + demo builds +
+
+ +
+ {/* Caliber filter */} +
+
+ Caliber +
+
+ {CALIBER_FILTERS.map((caliber) => ( + + ))} +
+
+ + {/* Class filter */} +
+
+ Class +
+
+ {CLASS_FILTERS.map((cls) => ( + + ))} +
+
+ + {/* Price filter */} +
+
+ Price Range +
+
+ {PRICE_FILTERS.map((range) => ( + + ))} +
+
+
+
+ + {/* Build list */} +
+
+ {filteredBuilds.map((build) => ( +
+ {/* Votes column */} +
+ +
+ {votes[build.id] ?? build.votes} +
+ +
+ + {/* Placeholder thumbnail */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`${build.title} +
+
+ + {/* Main content */} +
+
+
+
+ {build.buildClass} · {build.caliber} +
+

+ + {build.title} + +

+

+ Posted by{" "} + + b/{build.creator} + {" "} + · Placeholder build listing — details, parts list, and + comments will live on the build detail page later. +

+
+
+
+ Est. Build Cost +
+
+ ${build.price.toLocaleString()} +
+
+
+ +
+ {build.tags.map((tag) => ( + + {tag} + + ))} + + Comments, save, and share coming soon. + +
+
+
+ ))} + + {filteredBuilds.length === 0 && ( +
+ No builds match those filters yet. Once the real feed is wired + up, this will update live as the community posts rifles, pistols, + and NFA builds. +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/gunbuilder/[categoryId]/[partId]/page.tsx b/app/gunbuilder/[categoryId]/[partId]/page.tsx deleted file mode 100644 index 93529fd..0000000 --- a/app/gunbuilder/[categoryId]/[partId]/page.tsx +++ /dev/null @@ -1,298 +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; - mainImageUrl: 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; - const partId = params.partId as string; // this is the UUID we passed in the link - - 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?platform=AR-15`; - 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(); - const found = data.find((p) => p.id === partId) ?? null; - - if (!found) { - setError("Product not found"); - } - - setProduct(found); - } 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 Gunbuilder - -
-
- ); - } - - 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.mainImageUrl && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {product.name} -
- )} -
- {product.brand} -
-

- {product.name} -

-

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

-

- Role: {product.partRole} -

-
- - {/* Right: pricing + actions */} - -
- )} -
-
- ); -} \ No newline at end of file diff --git a/app/gunbuilder/[categoryId]/page.tsx b/app/gunbuilder/[categoryId]/page.tsx deleted file mode 100644 index db44bb7..0000000 --- a/app/gunbuilder/[categoryId]/page.tsx +++ /dev/null @@ -1,503 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { CATEGORIES } from "@/data/gunbuilderParts"; -import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings"; -import type { CategoryId, Part } from "@/types/gunbuilder"; - -type ViewMode = "card" | "list"; - -type GunbuilderProductFromApi = { - id: string; - name: string; - brand: string; - platform: string; - partRole: string; - price: number | null; - mainImageUrl: string | null; - buyUrl: string | null; -}; - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - - -// 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"; - -export default function CategoryPage() { - const params = useParams(); - const categoryId = params.categoryId as CategoryId; - const router = useRouter(); - - const [viewMode, setViewMode] = useState("list"); - const [parts, setParts] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // filter + sort state - const [brandFilter, setBrandFilter] = useState("all"); - const [sortBy, setSortBy] = useState("relevance"); - const [searchQuery, setSearchQuery] = useState(""); - - // build state for this page, synced with localStorage - const [build, setBuild] = useState(() => { - if (typeof window === "undefined") return {}; - try { - const stored = window.localStorage.getItem(STORAGE_KEY); - return stored ? (JSON.parse(stored) as BuildState) : {}; - } catch { - return {}; - } - }); - - const category = useMemo( - () => CATEGORIES.find((c) => c.id === categoryId), - [categoryId], - ); - - useEffect(() => { - if (!categoryId) return; - - const controller = new AbortController(); - - async function fetchCategoryParts() { - try { - setLoading(true); - setError(null); - - // FIX: determine which backend partRoles map to this category - const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? []; - - const search = new URLSearchParams(); - search.set("platform", "AR-15"); - roles.forEach((r) => search.append("partRoles", r)); - - const url = `${API_BASE_URL}/api/products/gunbuilder${ - roles.length ? `?${search.toString()}` : "" - }`; - - const res = await fetch(url, { signal: controller.signal }); - - if (!res.ok) { - throw new Error(`Failed to load products (${res.status})`); - } - - const data: GunbuilderProductFromApi[] = await res.json(); - - const normalized: Part[] = data.map((p) => ({ - id: p.id, - categoryId, - name: p.name, - brand: p.brand, - price: p.price ?? 0, - imageUrl: p.mainImageUrl ?? undefined, - url: p.buyUrl ?? undefined, - notes: undefined, - })); - - setParts(normalized); - } catch (err: any) { - if (err.name === "AbortError") return; - setError(err.message ?? "Failed to load products"); - } finally { - setLoading(false); - } - } - - fetchCategoryParts(); - - return () => controller.abort(); - }, [categoryId]); - - // 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; - - if (isSelected) { - // Remove from build - setBuild((prev) => { - const updated: BuildState = { ...prev }; - delete updated[categoryId]; - return updated; - }); - } else { - // Add to build and navigate back to main gunbuilder page - setBuild((prev) => ({ - ...prev, - [categoryId]: partId, - })); - router.push("/gunbuilder"); - } - }; - - // available brands for filter - const availableBrands = useMemo( - () => - Array.from(new Set(parts.map((p) => p.brand))) - .filter(Boolean) - .sort((a, b) => a.localeCompare(b)), - [parts], - ); - - // filtered + sorted parts with selected part pinned to top - const filteredParts = useMemo(() => { - let result = [...parts]; - - if (brandFilter !== "all") { - result = result.filter((p) => p.brand === brandFilter); - } - - const normalizedQuery = searchQuery.trim().toLowerCase(); - if (normalizedQuery) { - result = result.filter((p) => { - const name = p.name.toLowerCase(); - const brand = p.brand.toLowerCase(); - return ( - name.includes(normalizedQuery) || - brand.includes(normalizedQuery) - ); - }); - } - - switch (sortBy) { - case "price-asc": - result.sort((a, b) => a.price - b.price); - break; - case "price-desc": - result.sort((a, b) => b.price - a.price); - break; - case "brand-asc": - result.sort((a, b) => a.brand.localeCompare(b.brand)); - break; - case "relevance": - default: - // leave in backend order - break; - } - - // Pin the currently selected part (for this category) to the top, if any - const selectedId = build[categoryId]; - if (selectedId) { - const selected = result.filter((p) => p.id === selectedId); - const others = result.filter((p) => p.id !== selectedId); - result = [...selected, ...others]; - } - - return result; - }, [parts, brandFilter, sortBy, build, categoryId, searchQuery]); - - if (!category) { - return ( -
-
-
-

- Category Not Found -

- - Return to Gunbuilder - -
-
-
- ); - } - - return ( -
-
- {/* Header */} -
- - ← Back to The Armory - -
-
-

- Shadow Standard -

-

- {category.name} Parts -

-

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

-
- {!loading && !error && parts.length > 0 && ( -
- - -
- )} -
-
- - {/* Parts Grid/List */} -
- {/* Filter + sort toolbar */} - {!loading && !error && parts.length > 0 && ( -
-
- Showing{" "} - - {filteredParts.length} - {" "} - of{" "} - - {parts.length} - {" "} - parts -
-
-
- -
- setSearchQuery(e.target.value)} - placeholder={`Search ${category.name.toLowerCase()}...`} - className="rounded-md border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-200 px-2 py-1 pr-6 focus:outline-none focus:ring-1 focus:ring-amber-400" - /> - {searchQuery && ( - - )} -
-
-
- - -
-
- - -
-
-
- )} - - {loading ? ( -

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

- ) : error ? ( -

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

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

- No parts available for this category yet. -

- ) : viewMode === "card" ? ( -
- {filteredParts.map((part) => { - const isSelected = build[categoryId] === part.id; - - return ( -
-
-
-
- {part.brand}{" "} - — {part.name} -
- {part.notes && ( -

- {part.notes} -

- )} -
-
- ${part.price.toFixed(2)} -
-
-
- - View Details - - -
-
- ); - })} -
- ) : ( - <> - {/* Header row for list view */} -
- Part - Brand - Price - Actions -
- -
- {filteredParts.map((part) => { - const isSelected = build[categoryId] === part.id; - - return ( -
-
-
-
- {part.name} -
- {part.notes && ( -

- {part.notes} -

- )} -
-
- {part.brand} -
-
- ${part.price.toFixed(2)} -
-
- - View Details - - -
-
-
- ); - })} -
- - )} -
-
-
- ); -} \ No newline at end of file diff --git a/app/gunbuilder/admin/category-mappings/page.tsx b/app/gunbuilder/admin/category-mappings/page.tsx deleted file mode 100644 index 29d0d1c..0000000 --- a/app/gunbuilder/admin/category-mappings/page.tsx +++ /dev/null @@ -1,356 +0,0 @@ -"use client"; - -import { useEffect, useState, useMemo } from "react"; - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - -type Merchant = { - id: number; - name: string; -}; - -type Mapping = { - id: number | null; // might be null if just created client-side - merchantId: number; - merchantName: string; - rawCategory: string; - mappedPartRole: string | null; -}; - -type UpsertRequest = { - merchantId: number; - rawCategory: string; - mappedPartRole: string | null; -}; - -// Keep this in sync with your backend `partRole` values -const PART_ROLE_OPTIONS = [ - { value: "", label: "— Unmapped —" }, - // Lower / fire control - { value: "lower-receiver", label: "Lower Receiver" }, - { value: "lower-parts-kit", label: "Lower Parts Kit" }, - { value: "trigger", label: "Trigger / Trigger Kit" }, - { value: "pistol-grip", label: "Pistol Grip" }, - { value: "safety-selector", label: "Safety Selector" }, - - // Upper - { value: "upper-receiver", label: "Upper Receiver" }, - { value: "barrel", label: "Barrel" }, - { value: "handguard", label: "Handguard" }, - { value: "gas-system", label: "Gas System / Gas Block" }, - { value: "muzzle-device", label: "Muzzle Device" }, - { value: "suppressor", label: "Suppressor" }, - - // Furniture / sights / optics - { value: "stock", label: "Stock" }, - { value: "buffer-kit", label: "Buffer Kit" }, - { value: "charging-handle", label: "Charging Handle" }, - { value: "sight", label: "Iron / Backup Sight" }, - { value: "optic", label: "Optic" }, - { value: "other", label: "Other / Ignore" }, -]; - -export default function CategoryMappingsPage() { - const [merchants, setMerchants] = useState([]); - const [selectedMerchantId, setSelectedMerchantId] = useState( - null, - ); - const [mappings, setMappings] = useState([]); - const [loadingMappings, setLoadingMappings] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [showOnlyUnmapped, setShowOnlyUnmapped] = useState(false); - - const sortedMappings = useMemo(() => { - // Mapped rows first, then unmapped; within each group, alpha by rawCategory - return [...mappings].sort((a, b) => { - const aMapped = !!a.mappedPartRole; - const bMapped = !!b.mappedPartRole; - - if (aMapped !== bMapped) { - return aMapped ? -1 : 1; // mapped first - } - - return a.rawCategory.localeCompare(b.rawCategory); - }); - }, [mappings]); - - const visibleMappings = useMemo( - () => - showOnlyUnmapped - ? sortedMappings.filter((m) => !m.mappedPartRole) - : sortedMappings, - [sortedMappings, showOnlyUnmapped], - ); - - // Load merchants - useEffect(() => { - async function fetchMerchants() { - try { - const res = await fetch(`${API_BASE_URL}/admin/merchants`); - if (!res.ok) { - throw new Error(`Failed to load merchants (${res.status})`); - } - const data: Merchant[] = await res.json(); - setMerchants(data); - - // Auto-select the first merchant if you want - if (data.length > 0 && selectedMerchantId === null) { - setSelectedMerchantId(data[0].id); - } - } catch (err: any) { - console.error(err); - setError(err.message ?? "Failed to load merchants"); - } - } - - fetchMerchants(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Load mappings for selected merchant - useEffect(() => { - if (!selectedMerchantId) return; - - const controller = new AbortController(); - - async function fetchMappings() { - try { - setLoadingMappings(true); - setError(null); - setSuccessMessage(null); - - const url = `${API_BASE_URL}/admin/merchant-category-mappings?merchantId=${selectedMerchantId}`; - const res = await fetch(url, { signal: controller.signal }); - - if (!res.ok) { - throw new Error(`Failed to load mappings (${res.status})`); - } - - const data = (await res.json()) as Mapping[]; - setMappings(data); - } catch (err: any) { - if (err.name === "AbortError") return; - console.error(err); - setError(err.message ?? "Failed to load mappings"); - } finally { - setLoadingMappings(false); - } - } - - fetchMappings(); - - return () => controller.abort(); - }, [selectedMerchantId]); - - const handleChangePartRole = (rawCategory: string, newRole: string) => { - setMappings((prev) => - prev.map((m) => - m.rawCategory === rawCategory - ? { ...m, mappedPartRole: newRole || null } - : m, - ), - ); - }; - - const handleSave = async () => { - if (!selectedMerchantId) return; - setSaving(true); - setError(null); - setSuccessMessage(null); - - try { - // Save each mapping in parallel - const requests: Promise[] = mappings.map((m) => { - const payload: UpsertRequest = { - merchantId: selectedMerchantId, - rawCategory: m.rawCategory, - mappedPartRole: m.mappedPartRole, - }; - - return fetch(`${API_BASE_URL}/admin/merchant-category-mappings`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - }); - - const responses = await Promise.all(requests); - const failed = responses.filter((r) => !r.ok); - - if (failed.length > 0) { - throw new Error(`Some mappings failed to save (${failed.length})`); - } - - setSuccessMessage("Mappings saved. Re-run the import to apply changes."); - } catch (err: any) { - console.error(err); - setError(err.message ?? "Failed to save mappings"); - } finally { - setSaving(false); - } - }; - - const selectedMerchantName = - merchants.find((m) => m.id === selectedMerchantId)?.name ?? "—"; - - return ( -
-
-
-
-

- Shadow Standard / Admin -

-

- Merchant Category Mapping -

-

- Normalize each merchant's category names into your internal - part roles (upper, barrel, handguard, etc.). Unmapped categories - will be skipped during import until they are mapped here. -

-
- -
- - -
-
- -
- {error && ( -
- {error} -
- )} - {successMessage && ( -
- {successMessage} -
- )} - - {!selectedMerchantId ? ( -

- Choose a merchant above to view category mappings. -

- ) : loadingMappings ? ( -

- Loading mappings for {selectedMerchantName}… -

- ) : mappings.length === 0 ? ( -

- No category rows yet for this merchant. Once you run an import, - discovered categories will appear here automatically. -

- ) : ( - <> -
-
- Mappings for{" "} - {selectedMerchantName}.{" "} - Set a part role for each raw category. Unmapped categories will - be skipped by the importer. -
- -
- -
- - - - - - - - - - {visibleMappings.map((m) => ( - - - - - - ))} - -
Raw CategoryMapped Part RoleStatus
- {m.rawCategory} - - - - {m.mappedPartRole ? ( - - Mapped - - ) : ( - - Unmapped / Ignored - - )} -
-
- -
- -
- - )} -
-
-
- ); -} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index dc9a196..a1046f0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,16 +1,22 @@ -"use client"; import "./globals.css"; import type { ReactNode } from "react"; import { AuthProvider } from "@/context/AuthContext"; -import { TopNav } from "@/components/TopNav"; // or default import if that's how you exported it +import { TopNav } from "@/components/TopNav"; 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 RootLayout({ children }: { children: ReactNode }) { return ( - {/* AuthProvider wraps everything that uses useAuth */} diff --git a/app/lib/api/adminCategoryMappings.ts b/app/lib/api/adminCategoryMappings.ts deleted file mode 100644 index 5dd60d7..0000000 --- a/app/lib/api/adminCategoryMappings.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { apiFetch } from "@/lib/useApi"; - -export type AdminCategory = { - id: number; - slug: string; - name: string; - description?: string | null; - groupName?: string | null; - sortOrder?: number | null; -}; - -export type AdminPartRoleMapping = { - id: number; - platform: string; - partRole: string; - categorySlug: string; - categoryName: string; - groupName?: string | null; - notes?: string | null; -}; - -export async function fetchAdminCategories(token: string) { - return apiFetch("/api/admin/categories", { - token, - }); -} - -export async function fetchPartRoleMappings(token: string, platform = "AR-15") { - const params = new URLSearchParams({ platform }); - return apiFetch(`/api/admin/category-mappings?${params.toString()}`, { - token, - }); -} - -export async function createPartRoleMapping( - token: string, - payload: { - platform: string; - partRole: string; - categorySlug: string; - notes?: string; - }, -) { - return apiFetch("/api/admin/category-mappings", { - method: "POST", - token, - body: payload, - }); -} - -export async function updatePartRoleMapping( - token: string, - id: number, - payload: { - platform: string; - partRole: string; - categorySlug: string; - notes?: string; - }, -) { - return apiFetch(`/api/admin/category-mappings/${id}`, { - method: "PUT", - token, - body: payload, - }); -} - -export async function deletePartRoleMapping(token: string, id: number) { - await apiFetch(`/api/admin/category-mappings/${id}`, { - method: "DELETE", - token, - }); -} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 06baf7d..189c9c7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,27 +5,27 @@ export default function HomePage() {

- Shadow Standard Co. + For Those Who Build With Intent.

-

- The Build Bench +

+ Battl Builder

- Shadow Standard’s Armory Build Bench isn’t a catalog — it’s a - workbench for people who take their rifles, and their craft, - seriously. We pull live parts and pricing from the brands that matter, - lay everything out with purpose, and let you shape a rifle the same - way you’d shape a hunt or a mission: one smart choice at a time. Track - your build cost, swap components on the fly, save your setup, and - share it with the crew. No noise. No gimmicks. Just clean information - and the tools to build something worth carrying. + Battl Builder is your command center for rifle builds. We surface live + parts and hard-use brands, giving you a mission-ready workbench to + design smarter, track cost, swap components, and save setups. No + fluff. Just clean, trustworthy data.

- Open The Build Bench + Launch The Builder
diff --git a/components/PricingHistoryGraph.tsx b/components/PricingHistoryGraph.tsx index 558f124..a5c6f60 100644 --- a/components/PricingHistoryGraph.tsx +++ b/components/PricingHistoryGraph.tsx @@ -1,6 +1,6 @@ "use client"; -import type { PriceHistoryPoint } from "@/app/gunbuilder/[categoryId]/[partId]/data"; +import type { PriceHistoryPoint } from "@/app/builder/[categoryId]/[partId]/data"; interface PricingHistoryGraphProps { data: PriceHistoryPoint[]; diff --git a/components/RetailersList.tsx b/components/RetailersList.tsx index 88b99b3..89a3a63 100644 --- a/components/RetailersList.tsx +++ b/components/RetailersList.tsx @@ -1,6 +1,6 @@ "use client"; -import type { RetailerOffer } from "@/app/gunbuilder/[categoryId]/[partId]/data"; +import type { RetailerOffer } from "@/app/builder/[categoryId]/[partId]/data"; interface RetailersListProps { retailers: RetailerOffer[]; diff --git a/components/TopNav.tsx b/components/TopNav.tsx index f848c69..da1dab4 100644 --- a/components/TopNav.tsx +++ b/components/TopNav.tsx @@ -7,8 +7,11 @@ import { useAuth } from "@/context/AuthContext"; export function TopNav() { const { user, logout, loading } = useAuth(); + const isAuthenticated = !!user; + return (
+ {/* Early access bar */}
@@ -25,15 +28,36 @@ export function TopNav() {
+ + {/* Main nav row */}
- {/* Brand / Home link */} + {/* Left: Brand / Home */} - The Build Bench + Battl Builder + {/* Center: main nav (Builder / Admin) */} + + {/* Right side actions */}
{/* Search placeholder */} @@ -59,9 +83,9 @@ export function TopNav() { {loading ? ( Checking session… - ) : user ? ( + ) : isAuthenticated ? ( <> - + {user.displayName || user.email}