diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index 16ac22b..03c4178 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -92,10 +92,10 @@ const CATEGORY_GROUPS: { categoryIds: [ "lower-receiver", "complete-lower", - "lower-parts-kit", // ✅ was lower-parts + "lower-parts-kit", "trigger", "grip", - "safety-selector", // ✅ was safety + "safety-selector", "buffer", "stock", ], @@ -192,6 +192,21 @@ export default function GunbuilderPage() { return {}; }); + type PageResponse = { + content: T[]; + totalElements?: number; + totalPages?: number; + number?: number; + size?: number; + }; + + function unwrapPage(json: any): T[] { + if (!json) return []; + if (Array.isArray(json)) return json; + if (Array.isArray(json.content)) return json.content; + return []; + } + // ----------------------------- // Toast notifications (save success / errors) // ----------------------------- @@ -374,10 +389,11 @@ export default function GunbuilderPage() { setLoading(true); setError(null); + const size = 2000; // temporary "big bucket" until we optimize const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent( platform - )}`; - const universalUrl = `${API_BASE_URL}/api/v1/products`; + )}&page=0&size=${size}`; + const universalUrl = `${API_BASE_URL}/api/v1/products?page=0&size=${size}`; const [scopedRes, universalRes] = await Promise.all([ fetch(scopedUrl, { signal: controller.signal }), @@ -391,13 +407,15 @@ export default function GunbuilderPage() { throw new Error(`Failed to load products (${status})`); } - const scopedData: GunbuilderProductFromApi[] = await scopedRes.json(); + const scopedJson = await scopedRes.json(); + const scopedData: GunbuilderProductFromApi[] = + unwrapPage(scopedJson); let universalData: GunbuilderProductFromApi[] = []; if (universalRes && universalRes.ok) { - universalData = await universalRes.json(); + const universalJson = await universalRes.json(); + universalData = unwrapPage(universalJson); } - const normalize = (data: GunbuilderProductFromApi[]): Part[] => data .map((p): Part | null => { @@ -649,31 +667,30 @@ export default function GunbuilderPage() { } }; - // ----------------------------- -// Selection handler (with hint warnings) -// ----------------------------- -const handleSelectPart = useCallback( - ( - categoryId: BuilderSlotKey, - partId: string, - _opts?: { confirm?: boolean } - ) => { - const warn = (msg: string) => { - setShareStatus(msg); - window.setTimeout(() => setShareStatus(null), 4000); - }; + // Selection handler (with hint warnings) + // ----------------------------- + const handleSelectPart = useCallback( + ( + categoryId: BuilderSlotKey, + partId: string, + _opts?: { confirm?: boolean } + ) => { + const warn = (msg: string) => { + setShareStatus(msg); + window.setTimeout(() => setShareStatus(null), 4000); + }; - const hints = getSelectionHints(categoryId, build); - if (hints.length > 0) warn(hints[0]); + const hints = getSelectionHints(categoryId, build); + if (hints.length > 0) warn(hints[0]); - setBuild((prev) => ({ - ...prev, - [categoryId]: partId, - })); - }, - [build] -); + setBuild((prev) => ({ + ...prev, + [categoryId]: partId, + })); + }, + [build] + ); // ----------------------------- // Handle URL query parameters: // - ?select=categoryId:partId diff --git a/app/admin/products/page.tsx b/app/admin/products/page.tsx index 94b0e7e..35d7ce8 100644 --- a/app/admin/products/page.tsx +++ b/app/admin/products/page.tsx @@ -2,410 +2,603 @@ import { useEffect, useMemo, useState } from "react"; -type ProductSummary = { +type ProductVisibility = "PUBLIC" | "HIDDEN"; +type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED"; + +type AdminProductRow = { id: number; + uuid: string; name: string; - brand: string; - platform: string; - partRole: string; - price: number | null; - buyUrl: string | null; - imageUrl: string | null; + slug: string; + platform: string | null; + partRole: string | null; + brandName: string | null; + importStatus: string | null; + + visibility: ProductVisibility; + status: ProductStatus; + builderEligible: boolean; + adminLocked: boolean; + + adminNote: string | null; + mainImageUrl: string | null; + + createdAt: string | null; + updatedAt: string | null; }; -const API_BASE_URL = +type PageResponse = { + content: T[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +}; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; -type PlatformOption = { - key: string; // e.g. AR15, AR9 - label: string; // e.g. "Armalite Rifle model 15" -}; +function getCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const match = document.cookie.match( + new RegExp( + "(^| )" + name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") + "=([^;]+)" + ) + ); + return match ? decodeURIComponent(match[2]) : null; +} -// Backend currently filters products by canonical platform strings like "AR-15". -// DB platform keys are like "AR15" / "AR9". Translate when calling /api/v1/products. -const platformKeyToApiPlatform = (key: string) => { - const k = (key ?? "").toUpperCase().trim(); - if (!k) return "AR-15"; - if (k === "AR15" || k === "AR-15") return "AR-15"; - if (k === "AR10" || k === "AR-10") return "AR-10"; - if (k === "AR9" || k === "AR-9") return "AR-9"; - // Fallback: if it already contains a dash, assume it's canonical. - if (k.includes("-")) return k; - // Otherwise, best-effort: insert a dash after AR. - if (k.startsWith("AR") && k.length > 2) return `AR-${k.slice(2)}`; - return k; -}; +function getToken(): string | null { + if (typeof window === "undefined") return null; -const FALLBACK_PLATFORMS: PlatformOption[] = [ - { key: "AR15", label: "AR-15" }, - { key: "AR10", label: "AR-10" }, - { key: "AR9", label: "AR-9" }, -]; + // 1) Prefer localStorage (your canonical token store) + const ls = localStorage.getItem("ballistic_auth_token"); + if (ls && ls.trim().length > 0) return ls; + + // 2) Fallback to cookie (only works if NOT HttpOnly) + const ck = getCookie("bb_access_token"); + if (ck && ck.trim().length > 0) return ck; + + return null; +} + +function getRole(): string | null { + if (typeof window === "undefined") return null; + + const lsUser = localStorage.getItem("ballistic_auth_user"); + if (lsUser) { + try { + const parsed = JSON.parse(lsUser); + // adjust if your user object uses a different key + return parsed?.role ?? parsed?.roles?.[0] ?? null; + } catch { + // ignore + } + } + + const ckRole = getCookie("bb_role"); + return ckRole ?? null; +} + +function qs(params: Record) { + const sp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v === undefined || v === null || v === "") return; + sp.set(k, String(v)); + }); + return sp.toString(); +} + +async function fetchAdminProducts(params: Record) { + const token = getToken(); + + const res = await fetch(`${API_BASE}/api/v1/admin/products?${qs(params)}`, { + method: "GET", + credentials: "include", + headers: { + Accept: "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + cache: "no-store", + }); + + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Failed to load admin products (${res.status}): ${txt}`); + } + + return (await res.json()) as PageResponse; +} + +async function bulkUpdate( + productIds: number[], + set: Partial<{ + visibility: ProductVisibility; + status: ProductStatus; + builderEligible: boolean; + adminLocked: boolean; + adminNote: string; + }> +) { + const token = getToken(); + + const res = await fetch(`${API_BASE}/api/v1/admin/products/bulk`, { + method: "PATCH", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ productIds, ...set }), + }); + + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Bulk update failed (${res.status}): ${txt}`); + } + + return (await res.json()) as { updatedCount: number }; +} export default function AdminProductsPage() { - const [platforms, setPlatforms] = useState(FALLBACK_PLATFORMS); - const [platform, setPlatform] = useState("AR15"); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [products, setProducts] = useState([]); - const [query, setQuery] = useState(""); - - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(50); - - // Load available platforms (dynamic if backend supports it; otherwise fallback) - useEffect(() => { - const controller = new AbortController(); - - async function loadPlatforms() { - try { - const res = await fetch(`${API_BASE_URL}/api/platforms`, { - signal: controller.signal, - }); - if (!res.ok) return; - - const data = (await res.json()) as unknown; - - // Expected shape: - // [{ id, key, label, is_active, ... }] - const items = Array.isArray(data) ? data : []; - - const cleaned: PlatformOption[] = items - .map((item) => { - if (!item || typeof item !== "object") return null; - const obj = item as Record; - - const key = typeof obj.key === "string" ? obj.key.trim() : ""; - const label = typeof obj.label === "string" ? obj.label.trim() : ""; - - // Some payloads might use isActive; yours currently uses is_active - const activeRaw = obj.is_active ?? obj.isActive; - const isActive = - typeof activeRaw === "boolean" - ? activeRaw - : typeof activeRaw === "number" - ? activeRaw === 1 - : true; - - if (!key || !label || !isActive) return null; - return { key, label } as PlatformOption; - }) - .filter((v): v is PlatformOption => v !== null); - - if (cleaned.length === 0) return; - - // Keep them stable and unique by key - const byKey = new Map(); - for (const p of cleaned) byKey.set(p.key, p); - const unique = Array.from(byKey.values()); - - setPlatforms(unique); - setPlatform((prev) => (unique.some((p) => p.key === prev) ? prev : unique[0].key)); - } catch (err: any) { - if (err?.name === "AbortError") return; - // ignore (fallback list stays) - } - } - - loadPlatforms(); - return () => controller.abort(); - }, []); - - // Load products for selected platform - useEffect(() => { - const controller = new AbortController(); - - async function loadProducts() { - try { - setLoading(true); - setError(null); - - const apiPlatform = platformKeyToApiPlatform(platform); - - // NOTE: backend endpoint expects `platform`, not `platformKey`. - const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent( - apiPlatform - )}`; - - const res = await fetch(url, { signal: controller.signal }); - if (!res.ok) { - throw new Error(`Failed to load products (${res.status})`); - } - - const data = (await res.json()) as ProductSummary[]; - setProducts(Array.isArray(data) ? data : []); - setPage(1); - } catch (err: any) { - if (err?.name === "AbortError") return; - setError(err?.message ?? "Failed to load products"); - setProducts([]); - } finally { - setLoading(false); - } - } - - loadProducts(); - return () => controller.abort(); - }, [platform]); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return products; - - return products.filter((p) => { - const name = (p.name ?? "").toLowerCase(); - const brand = (p.brand ?? "").toLowerCase(); - const role = (p.partRole ?? "").toLowerCase(); - return name.includes(q) || brand.includes(q) || role.includes(q); - }); - }, [products, query]); - - // Reset to page 1 when the search query changes - useEffect(() => { - setPage(1); - }, [query, platform, pageSize]); - - const totalPages = useMemo(() => { - const total = filtered.length; - return total === 0 ? 1 : Math.ceil(total / pageSize); - }, [filtered.length, pageSize]); - - const clampedPage = useMemo(() => { - if (page < 1) return 1; - if (page > totalPages) return totalPages; - return page; - }, [page, totalPages]); - - const pageStart = useMemo(() => (clampedPage - 1) * pageSize, [clampedPage, pageSize]); - const pageEndExclusive = useMemo( - () => Math.min(pageStart + pageSize, filtered.length), - [pageStart, pageSize, filtered.length] + // filters + const [q, setQ] = useState(""); + const [platform, setPlatform] = useState(""); + const [partRole, setPartRole] = useState(""); + const [visibility, setVisibility] = useState(""); + const [status, setStatus] = useState(""); + const [builderEligible, setBuilderEligible] = useState<"" | "true" | "false">( + "" ); + const [adminLocked, setAdminLocked] = useState<"" | "true" | "false">(""); - const paginated = useMemo(() => { - return filtered.slice(pageStart, pageEndExclusive); - }, [filtered, pageStart, pageEndExclusive]); + // paging + const [page, setPage] = useState(0); + const [size, setSize] = useState(50); + + // data + const [data, setData] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + + // selection + const [selected, setSelected] = useState>(new Set()); + + const queryParams = useMemo(() => { + return { + q, + platform, + partRole, + visibility, + status, + builderEligible: builderEligible === "" ? null : builderEligible, + adminLocked: adminLocked === "" ? null : adminLocked, + page, + size, + sort: "updatedAt,desc", + }; + }, [ + q, + platform, + partRole, + visibility, + status, + builderEligible, + adminLocked, + page, + size, + ]); + + const refresh = async () => { + setLoading(true); + setErr(null); + try { + const res = await fetchAdminProducts(queryParams); + setData(res); + // clear selection on refresh so you don’t accidentally nuke new stuff + setSelected(new Set()); + } catch (e: any) { + setErr(e?.message ?? "Failed to load."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryParams.page, queryParams.size]); // only auto-refresh on page/size + + const content = data?.content ?? []; + + const allOnPageSelected = + content.length > 0 && content.every((r) => selected.has(r.id)); + + const toggleAllOnPage = () => { + const next = new Set(selected); + if (allOnPageSelected) { + content.forEach((r) => next.delete(r.id)); + } else { + content.forEach((r) => next.add(r.id)); + } + setSelected(next); + }; + + const toggleOne = (id: number) => { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelected(next); + }; + + const selectedIds = Array.from(selected); + + const runBulk = async (set: Parameters[1]) => { + if (selectedIds.length === 0) return; + + // no confirmations per your “keep moving” vibe, but you can add one later + setLoading(true); + setErr(null); + + try { + const res = await bulkUpdate(selectedIds, set); + // eslint-disable-next-line no-console + console.log("Bulk updated:", res.updatedCount); + await refresh(); + } catch (e: any) { + setErr(e?.message ?? "Bulk update failed."); + setLoading(false); + } + }; return ( -
-
-
-

- Admin +

+
+
+

Admin · Products

+

+ Filter, select, and apply bulk actions. This is where bad imports + come to die.

-

- Products Catalog -

-

- Read-only view of products coming from the Ballistic backend. Filter by - platform, search, and spot-check pricing + buy links. -

-
+
-
-
-
- - -
- - setQuery(e.target.value)} - placeholder="brand, name, part role…" - className="w-64 max-w-full rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400" - /> -
- - -
- -
- {loading ? ( - Loading… - ) : error ? ( - {error} - ) : ( - - Showing{" "} - - {filtered.length === 0 ? 0 : pageStart + 1}-{pageEndExclusive} - - {" "}of{" "} - {filtered.length} - {" "}{filtered.length === 1 ? "product" : "products"} - - )} -
-
- - {!loading && !error && filtered.length > 0 && ( -
-
- Page {clampedPage} of{" "} - {totalPages} -
- -
- - -
-
- )} - -
- - - - - - - - - - - - - {loading ? ( - - - - ) : error ? ( - - - - ) : paginated.length === 0 ? ( - - - - ) : ( - paginated.map((p) => ( - - - - - - - - - )) - )} - -
ProductBrandPlatformPart RolePriceLink
- Loading products… -
- {error} -
- No products found. -
-
{p.name}
-
ID: {p.id}
-
{p.brand} - {p.platform ?? platformKeyToApiPlatform(platform)} - {p.partRole} - {p.price == null ? "—" : `$${Number(p.price).toFixed(2)}`} - - {p.buyUrl ? ( - - Open - - ) : ( - - )} -
-
- - {!loading && !error && filtered.length > 0 && ( -
- - - - Page{" "} - - {clampedPage} - {" "} - of{" "} - - {totalPages} - - - - -
- )} - -
- Backend: {API_BASE_URL} · Endpoint: /api/v1/products?platform=... · Page size: {pageSize} -
-
+ -
+ + {/* Filters */} +
+ setQ(e.target.value)} + placeholder="Search (name, slug, mpn, upc)…" + className="md:col-span-2 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" + /> + + setPlatform(e.target.value)} + placeholder="Platform (AR-15)…" + className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" + /> + + setPartRole(e.target.value)} + placeholder="Part role (barrel)…" + className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" + /> + + + + + +
+ + + + + + + +
+
+ + {/* Bulk actions */} +
+
+ Selected:{" "} + + {selectedIds.length} + +
+ +
+ + + + + + + + + + + +
+
+ + {/* Errors */} + {err && ( +
+ {err} +
+ )} + + {/* Table */} +
+ + + + + + + + + + + + + + + + {loading && ( + + + + )} + + {!loading && content.length === 0 && ( + + + + )} + + {!loading && + content.map((row) => ( + + + + + + + + + + + + + + + ))} + +
+ + ProductBrandPlatformRoleImportFlagsUpdated
+ Loading… +
+ No results. +
+ toggleOne(row.id)} + /> + +
+ {row.mainImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} + +
+
{row.name}
+
+ {row.slug} · #{row.id} +
+ {row.adminNote ? ( +
+ 📝 {row.adminNote} +
+ ) : null} +
+
+
{row.brandName ?? "—"}{row.platform ?? "—"}{row.partRole ?? "—"}{row.importStatus ?? "—"} +
+ {row.visibility === "HIDDEN" ? ( + + HIDDEN + + ) : null} + {row.status !== "ACTIVE" ? ( + + {row.status} + + ) : null} + {row.builderEligible === false ? ( + + NO_BUILDER + + ) : null} + {row.adminLocked ? ( + + LOCKED + + ) : null} +
+
+ {row.updatedAt ?? "—"} +
+
+ + {/* Pagination */} +
+
+ Page {(data?.number ?? 0) + 1} of {data?.totalPages ?? 1} + {data?.totalElements ?? 0} total +
+ +
+ + + + + +
+
+ ); -} \ No newline at end of file +} diff --git a/components/parts/PartsBrowseClient.tsx b/components/parts/PartsBrowseClient.tsx index fa908c5..b740e84 100644 --- a/components/parts/PartsBrowseClient.tsx +++ b/components/parts/PartsBrowseClient.tsx @@ -20,12 +20,28 @@ import PartsGrid from "@/components/parts/PartsGrid"; import Pagination from "@/components/parts/Pagination"; import PlatformSwitcher from "@/components/parts/PlatformSwitcher"; -import type { CategoryId } from "@/types/builderSlots"; +import type { Category } from "@/types/builderSlots"; import { PART_ROLE_TO_CATEGORY, normalizePartRole, } from "@/lib/catalogMappings"; +type PageResponse = { + content: T[]; + totalElements: number; + totalPages: number; + number: number; // 0-based + size: number; +}; + +function unwrapContent(json: any): { items: T[]; page?: PageResponse } { + if (!json) return { items: [] }; + if (Array.isArray(json)) return { items: json }; + if (Array.isArray(json.content)) + return { items: json.content, page: json as PageResponse }; + return { items: [] }; +} + type ViewMode = "card" | "list"; type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; @@ -116,36 +132,62 @@ export default function PartsBrowseClient(props: { max: null, }); const [inStockOnly, setInStockOnly] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - + const [currentPage, setCurrentPage] = useState(1); // UI 1-based + const [serverTotalPages, setServerTotalPages] = useState(1); + const [serverTotalElements, setServerTotalElements] = useState(0); // ---------------------------- // Fetch parts // ---------------------------- useEffect(() => { if (!partRole) return; - + const controller = new AbortController(); - + async function fetchParts() { try { setLoading(true); setError(null); - + const search = new URLSearchParams(); search.set("platform", effectivePlatform); search.append("partRoles", partRole); - - const res = await fetch( - `${API_BASE_URL}/api/v1/products?${search.toString()}`, - { signal: controller.signal } - ); - + + // server paging (0-based) + search.set("page", String(currentPage - 1)); + search.set("size", String(PAGE_SIZE)); + + // optional server search + sort (backend can ignore for now) + if (searchQuery.trim()) search.set("q", searchQuery.trim()); + if (brandFilter.length) brandFilter.forEach((b) => search.append("brand", b)); + + // sort mapping + switch (sortBy) { + case "price-asc": + search.set("sort", "price,asc"); + break; + case "price-desc": + search.set("sort", "price,desc"); + break; + case "brand-asc": + search.set("sort", "brand,asc"); + break; + case "relevance": + default: + search.set("sort", "updatedAt,desc"); + break; + } + + const res = await fetch(`${API_BASE_URL}/api/v1/products?${search.toString()}`, { + signal: controller.signal, + }); + if (!res.ok) throw new Error(`Failed to load products (${res.status})`); - - const data: GunbuilderProductFromApi[] = await res.json(); - + + const json = await res.json(); + const { items, page } = unwrapContent(json); + setParts( - data.map((p) => ({ + items.map((p) => ({ id: normalizeId(p.id), name: p.name, brand: p.brand, @@ -157,6 +199,15 @@ export default function PartsBrowseClient(props: { inStock: p.inStock ?? true, })) ); + + if (page) { + setServerTotalPages(page.totalPages ?? 1); + setServerTotalElements(page.totalElements ?? items.length); + } else { + // backward compat + setServerTotalPages(1); + setServerTotalElements(items.length); + } } catch (err: any) { if (err?.name === "AbortError") return; setError(err?.message ?? "Failed to load products"); @@ -164,11 +215,10 @@ export default function PartsBrowseClient(props: { setLoading(false); } } - + fetchParts(); return () => controller.abort(); - }, [partRole, effectivePlatform]); - + }, [partRole, effectivePlatform, currentPage, searchQuery, sortBy, brandFilter]); // Reset pagination on filters useEffect(() => { setCurrentPage(1); @@ -265,15 +315,12 @@ export default function PartsBrowseClient(props: { return result; }, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]); - const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE)); - const paginatedParts = filteredParts.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE - ); + const totalPages = Math.max(1, serverTotalPages); + const paginatedParts = filteredParts; // filteredParts should become just parts now (see below) const visibleRange = { - start: filteredParts.length ? (currentPage - 1) * PAGE_SIZE + 1 : 0, - end: Math.min(currentPage * PAGE_SIZE, filteredParts.length), + start: serverTotalElements ? (currentPage - 1) * PAGE_SIZE + 1 : 0, + end: Math.min(currentPage * PAGE_SIZE, serverTotalElements), }; const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`; @@ -284,7 +331,7 @@ export default function PartsBrowseClient(props: { // ---------------------------- const handleAddToBuild = (p: UiPart) => { const normalizedRole = normalizePartRole(partRole); - const categoryId: CategoryId | null = + const categoryId: Category | null = PART_ROLE_TO_CATEGORY[partRole] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null; diff --git a/data/gunbuilderParts.ts b/data/gunbuilderParts.ts index 9ae7e8e..ee76829 100644 --- a/data/gunbuilderParts.ts +++ b/data/gunbuilderParts.ts @@ -7,7 +7,7 @@ export const CATEGORIES: Category[] = [ { id: "complete-lower", name: "Complete Lower", group: "lower" }, { id: "lower-parts-kit", name: "Lower Parts Kit", group: "lower" }, { id: "trigger", name: "Trigger / Fire Control", group: "lower" }, - { id: "grip", name: "Pistol Grip", group: "lower" }, + { id: "grip", name: "Grips", group: "lower" }, { id: "safety-selector", name: "Safety / Selector", group: "lower" }, { id: "buffer", name: "Buffer System", group: "lower" }, { id: "stock", name: "Stock / Brace", group: "lower" },