-
+
{group.name}
@@ -673,7 +673,7 @@ export default function BuildPage() {
{selected.name}
@@ -709,11 +709,6 @@ export default function BuildPage() {
)}
-
-
- {component.notes}
-
-
{selected ? (
) : (
Find Parts
@@ -739,7 +734,7 @@ export default function BuildPage() {
})
) : (
-
+
No components found
Try adjusting your filters or search terms
@@ -757,7 +752,7 @@ export default function BuildPage() {
Showing {sortedComponents.length} of {allComponents.length} components
{hasActiveFilters && (
-
+
(filtered)
)}
diff --git a/src/app/(main)/builds/page.tsx b/src/app/(main)/builds/page.tsx
index d345715..0897317 100644
--- a/src/app/(main)/builds/page.tsx
+++ b/src/app/(main)/builds/page.tsx
@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
Completed
-
{inProgressMyBuilds}
+
{inProgressMyBuilds}
In Progress
diff --git a/src/app/(main)/my-builds/page.tsx b/src/app/(main)/my-builds/page.tsx
index 57e380c..30f324d 100644
--- a/src/app/(main)/my-builds/page.tsx
+++ b/src/app/(main)/my-builds/page.tsx
@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
Completed
-
{inProgressMyBuilds}
+
{inProgressMyBuilds}
In Progress
diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx
index 676be36..d47fc70 100644
--- a/src/app/(main)/page.tsx
+++ b/src/app/(main)/page.tsx
@@ -34,7 +34,7 @@ export default function LandingPage() {
Get Building
@@ -52,6 +52,7 @@ export default function LandingPage() {
{/* Beta Tester CTA */}
+
);
diff --git a/src/app/(main)/parts/[category]/page.tsx b/src/app/(main)/parts/[category]/page.tsx
new file mode 100644
index 0000000..d5eb557
--- /dev/null
+++ b/src/app/(main)/parts/[category]/page.tsx
@@ -0,0 +1,186 @@
+'use client';
+import { useEffect, useState, useMemo } from 'react';
+import { useParams } from 'next/navigation';
+
+const columns = [
+ 'brandName',
+ 'productName',
+ 'department',
+ 'category',
+ 'subcategory',
+ 'retailPrice',
+ 'salePrice',
+ 'imageUrl',
+];
+
+export default function PartsCategoryPage() {
+ const params = useParams();
+ const categoryParam = params?.category as string;
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [limit, setLimit] = useState(50);
+
+ // Filter state
+ const [brand, setBrand] = useState('');
+ const [department, setDepartment] = useState('');
+ const [subcategory, setSubcategory] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setError(null);
+ fetch(`/api/products?page=1&limit=10000`)
+ .then(res => res.json())
+ .then(data => {
+ setProducts(data.data || []);
+ setLoading(false);
+ })
+ .catch(err => {
+ setError(err.message || 'Error fetching products');
+ setLoading(false);
+ });
+ }, []);
+
+ // Get unique filter options from all products in this category
+ const filteredByCategory = useMemo(() => {
+ if (!categoryParam) return [];
+ // Normalize category param to slug (kebab-case, lowercased)
+ const slugify = (str: string) => str?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
+ const paramSlug = slugify(categoryParam);
+ return products.filter(p => {
+ const cat = p.category || '';
+ const catSlug = slugify(cat);
+ // Match by slug, by lowercased, or by plural/singular
+ return (
+ catSlug === paramSlug ||
+ cat.toLowerCase() === categoryParam.toLowerCase() ||
+ cat.toLowerCase() === categoryParam.toLowerCase().replace(/s$/, '') ||
+ cat.toLowerCase().replace(/s$/, '') === categoryParam.toLowerCase()
+ );
+ });
+ }, [products, categoryParam]);
+
+ const brandOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.brandName).filter(Boolean))).sort(), [filteredByCategory]);
+ const departmentOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.department).filter(Boolean))).sort(), [filteredByCategory]);
+ const subcategoryOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.subcategory).filter(Boolean))).sort(), [filteredByCategory]);
+
+ // Further filter by sidebar filters
+ const filteredProducts = useMemo(() => {
+ return filteredByCategory.filter(p =>
+ (!brand || p.brandName === brand) &&
+ (!department || p.department === department) &&
+ (!subcategory || p.subcategory === subcategory)
+ );
+ }, [filteredByCategory, brand, department, subcategory]);
+
+ // Pagination
+ const totalPages = Math.ceil(filteredProducts.length / limit);
+ const paginatedProducts = filteredProducts.slice((page - 1) * limit, page * limit);
+
+ // Reset to page 1 when filters change
+ useEffect(() => { setPage(1); }, [brand, department, subcategory, limit, categoryParam]);
+
+ // If category is not found
+ if (!categoryParam) {
+ return Category not found.
;
+ }
+
+ return (
+
+
+ {/* Debug block */}
+
+
categoryParam: {categoryParam}
+
First 5 categories in products: {[...new Set(products.map(p => p.category))].slice(0,5).join(', ')}
+
+ {/* Sidebar Filters */}
+
+
+
+ Brand
+ setBrand(e.target.value)}>
+ All
+ {brandOptions.map(opt => {opt} )}
+
+
+
+ Department
+ setDepartment(e.target.value)}>
+ All
+ {departmentOptions.map(opt => {opt} )}
+
+
+
+ Subcategory
+ setSubcategory(e.target.value)}>
+ All
+ {subcategoryOptions.map(opt => {opt} )}
+
+
+
+
+ {/* Products Table */}
+
+ {categoryParam} Parts
+
+
+
+
+ {columns.map(col => (
+ {col}
+ ))}
+
+
+
+ {loading ? (
+ Loading...
+ ) : paginatedProducts.length === 0 ? (
+ No products found.
+ ) : (
+ paginatedProducts.map((product, i) => (
+
+ {columns.map(col => (
+
+ {col === 'imageUrl' && product[col] ? (
+
+ ) : (
+ product[col] ?? ''
+ )}
+
+ ))}
+
+ ))
+ )}
+
+
+
+ {/* Pagination Controls */}
+
+ setPage(p => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >Prev
+ Page {page} of {totalPages || 1}
+ setPage(p => p + 1)}
+ disabled={page >= totalPages}
+ >Next
+ { setLimit(Number(e.target.value)); setPage(1); }}
+ >
+ {[25, 50, 100, 200].map(opt => (
+ {opt} / page
+ ))}
+
+ Total: {filteredProducts.length}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(main)/parts/page.tsx b/src/app/(main)/parts/page.tsx
index b5a881b..cd3da06 100644
--- a/src/app/(main)/parts/page.tsx
+++ b/src/app/(main)/parts/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
@@ -12,7 +12,6 @@ import Link from 'next/link';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page';
-import { categoryToComponentType, standardizedComponentTypes, mapToBuilderType, builderCategories, subcategoryMapping } from './categoryMapping';
// Product type (copied from mock/product for type safety)
type Product = {
@@ -170,7 +169,7 @@ const Dropdown = ({
{option.label}
{selected ? (
-
+
) : null}
@@ -222,11 +221,6 @@ const getComponentCategory = (productCategory: string): string => {
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
};
-// Map product categories to specific checklist component names
-const getMatchingComponentName = (productCategory: string): string => {
- return categoryToComponentType[productCategory] || '';
-};
-
// Pagination Component
const Pagination = ({
currentPage,
@@ -344,640 +338,218 @@ const Pagination = ({
);
};
-export default function Home() {
- const searchParams = useSearchParams();
- const router = useRouter();
- const [products, setProducts] = useState([]);
+// --- Canonical Category Fetch ---
+const useCanonicalCategories = () => {
+ const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- // Filters
- const [selectedCategoryId, setSelectedCategoryId] = useState('all');
- const [selectedSubcategoryId, setSelectedSubcategoryId] = useState('all');
- const [selectedBrand, setSelectedBrand] = useState('All');
- const [selectedVendor, setSelectedVendor] = useState('All');
- const [priceRange, setPriceRange] = useState('');
- const [searchTerm, setSearchTerm] = useState('');
- const [selectedRestriction, setSelectedRestriction] = useState('');
- const [sortField, setSortField] = useState('name');
- const [sortDirection, setSortDirection] = useState('asc');
- const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
- const [addedPartIds, setAddedPartIds] = useState([]);
- const [isSearchExpanded, setIsSearchExpanded] = useState(false);
-
- // Pagination state
- const [currentPage, setCurrentPage] = useState(1);
- const [itemsPerPage, setItemsPerPage] = useState(20);
- const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
- const selectedParts = useBuildStore((state) => state.selectedParts);
- const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
-
- // Fetch live data from /api/test-products
useEffect(() => {
- setLoading(true);
- fetch('/api/test-products')
+ fetch('/api/product-categories')
.then(res => res.json())
.then(data => {
- if (data.success && Array.isArray(data.data)) {
- // Map API data to Product type
- const mapped: Product[] = data.data.slice(0, 50).map((item: any) => ({
- id: item.uuid,
- name: item.productName,
- description: item.shortDescription || item.longDescription || '',
- longDescription: item.longDescription,
- image_url: item.imageUrl || item.thumbUrl || '/window.svg',
- images: [item.imageUrl, item.thumbUrl].filter(Boolean),
- brand: {
- id: item.brandName || 'unknown',
- name: item.brandName || 'Unknown',
- logo: item.brandLogoImage || '',
- },
- category: {
- id: item.category || 'unknown',
- name: item.category || 'Unknown',
- },
- subcategory: item.subcategory,
- offers: [
- {
- price: parseFloat(item.salePrice || item.retailPrice || '0'),
- url: item.buyLink || '',
- vendor: {
- name: 'Brownells', // Static for now, or parse from buyLink if needed
- logo: '',
- },
- inStock: true,
- shipping: '',
- },
- ],
- restrictions: {}, // Could infer from department/category if needed
- slug: item.slug || '',
- }));
- setProducts(mapped);
- // Log unique categories for mapping
- const uniqueCategories = Array.from(new Set(mapped.map(p => p.category.name)));
- console.log('Unique categories from live data:', uniqueCategories);
- } else {
- setError('No data returned from API');
- }
+ setCategories(data.data);
+ setLoading(false);
+ });
+ }, []);
+ return { categories, loading };
+};
+
+// --- Flatten category tree for dropdowns ---
+function flattenCategories(categories: any[], parent: any = null, depth = 0): any[] {
+ let result: any[] = [];
+ for (const cat of categories) {
+ result.push({ ...cat, depth, parent });
+ if (cat.children && cat.children.length > 0) {
+ result = result.concat(flattenCategories(cat.children, cat, depth + 1));
+ }
+ }
+ return result;
+}
+
+// --- Helper: Get all descendant category IDs ---
+function getDescendantCategoryIds(categories: any[], selectedId: string): string[] {
+ const result: string[] = [];
+ function traverse(nodes: any[]) {
+ for (const node of nodes) {
+ if (String(node.id) === selectedId) {
+ collect(node);
+ } else if (node.children && node.children.length > 0) {
+ traverse(node.children);
+ }
+ }
+ }
+ function collect(node: any) {
+ result.push(String(node.id));
+ if (node.children && node.children.length > 0) {
+ for (const child of node.children) {
+ collect(child);
+ }
+ }
+ }
+ traverse(categories);
+ return result;
+}
+
+const columns = [
+ 'brandName',
+ 'productName',
+ 'department',
+ 'category',
+ 'subcategory',
+ 'retailPrice',
+ 'salePrice',
+ 'imageUrl',
+];
+
+export default function PartsPage() {
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [limit, setLimit] = useState(50);
+
+ // Filter state
+ const [brand, setBrand] = useState('');
+ const [department, setDepartment] = useState('');
+ const [category, setCategory] = useState('');
+ const [subcategory, setSubcategory] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setError(null);
+ fetch(`/api/products?page=1&limit=10000`)
+ .then(res => res.json())
+ .then(data => {
+ setProducts(data.data || []);
setLoading(false);
})
.catch(err => {
- setError(String(err));
+ setError(err.message || 'Error fetching products');
setLoading(false);
});
}, []);
- // Extract unique values for dropdowns from live data
- const categories = [{ id: 'all', name: 'All Categories' }, ...builderCategories.map(cat => ({ id: cat.id, name: cat.name }))];
- const brands = [{ value: 'All', label: 'All Brands' }, ...Array.from(new Set(products.map(part => part.brand.name))).map(name => ({ value: name, label: name }))];
- const vendors = [{ value: 'All', label: 'All Vendors' }, ...Array.from(new Set(products.flatMap(part => part.offers.map(offer => offer.vendor.name)))).map(name => ({ value: name, label: name }))];
+ // Get unique filter options from all products
+ const brandOptions = useMemo(() => Array.from(new Set(products.map(p => p.brandName).filter(Boolean))).sort(), [products]);
+ const departmentOptions = useMemo(() => Array.from(new Set(products.map(p => p.department).filter(Boolean))).sort(), [products]);
+ const categoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.category).filter(Boolean))).sort(), [products]);
+ const subcategoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.subcategory).filter(Boolean))).sort(), [products]);
- // Read category from URL parameter on page load
- useEffect(() => {
- const categoryParam = searchParams.get('category');
- if (categoryParam && categories.some(c => c.id === categoryParam)) {
- setSelectedCategoryId(categoryParam);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchParams, categories.map(c => c.id).join(',')]);
+ // Filter products before rendering
+ const filteredProducts = useMemo(() => {
+ return products.filter(p =>
+ (!brand || p.brandName === brand) &&
+ (!department || p.department === department) &&
+ (!category || p.category === category) &&
+ (!subcategory || p.subcategory === subcategory)
+ );
+ }, [products, brand, department, category, subcategory]);
- const selectedCategory = builderCategories.find(cat => cat.id === selectedCategoryId);
- const subcategoryOptions = selectedCategory
- ? [{ id: 'all', name: 'All Subcategories' }, ...selectedCategory.subcategories]
- : [];
+ // Pagination
+ const totalPages = Math.ceil(filteredProducts.length / limit);
+ const paginatedProducts = filteredProducts.slice((page - 1) * limit, page * limit);
- // Filter parts based on selected criteria
- const filteredParts = products.filter(part => {
- const mappedSubcat = subcategoryMapping[part.subcategory || ''];
- const matchesCategory = selectedCategoryId === 'all' || (selectedCategory && selectedCategory.subcategories.some(sub => sub.id === mappedSubcat));
- const matchesSubcategory = selectedSubcategoryId === 'all' || mappedSubcat === selectedSubcategoryId;
- const matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
- const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
- const matchesSearch = !searchTerm ||
- part.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
- part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
- part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
-
- // Restriction filter logic (no real data, so always true)
- let matchesRestriction = true;
- if (selectedRestriction) {
- matchesRestriction = false;
- }
-
- // Price range filtering
- let matchesPrice = true;
- if (priceRange) {
- const lowestPrice = Math.min(...part.offers.map(offer => offer.price));
- switch (priceRange) {
- case 'under-100':
- matchesPrice = lowestPrice < 100;
- break;
- case '100-300':
- matchesPrice = lowestPrice >= 100 && lowestPrice <= 300;
- break;
- case '300-500':
- matchesPrice = lowestPrice > 300 && lowestPrice <= 500;
- break;
- case 'over-500':
- matchesPrice = lowestPrice > 500;
- break;
- }
- }
-
- return matchesCategory && matchesSubcategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
- });
-
- // Sort parts
- const sortedParts = [...filteredParts].sort((a, b) => {
- let aValue: any, bValue: any;
- if (sortField === 'price') {
- aValue = Math.min(...a.offers.map(offer => offer.price));
- bValue = Math.min(...b.offers.map(offer => offer.price));
- } else if (sortField === 'category') {
- aValue = a.category.name.toLowerCase();
- bValue = b.category.name.toLowerCase();
- } else {
- aValue = a.name.toLowerCase();
- bValue = b.name.toLowerCase();
- }
- if (sortDirection === 'asc') {
- return aValue > bValue ? 1 : -1;
- } else {
- return aValue < bValue ? 1 : -1;
- }
- });
-
- // Pagination logic
- const totalPages = Math.ceil(sortedParts.length / itemsPerPage);
- const startIndex = (currentPage - 1) * itemsPerPage;
- const endIndex = startIndex + itemsPerPage;
- const paginatedParts = sortedParts.slice(startIndex, endIndex);
-
- // Reset to first page when filters change
- useEffect(() => {
- setCurrentPage(1);
- }, [selectedCategoryId, selectedSubcategoryId, selectedBrand, selectedVendor, searchTerm, priceRange, selectedRestriction, sortField, sortDirection]);
-
- const handleSort = (field: SortField) => {
- if (sortField === field) {
- setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
- } else {
- setSortField(field);
- setSortDirection('asc');
- }
- };
-
- const getSortIcon = (field: SortField) => {
- if (sortField !== field) {
- return '↕️';
- }
- return sortDirection === 'asc' ? '↑' : '↓';
- };
-
- const clearFilters = () => {
- setSelectedCategoryId('all');
- setSelectedSubcategoryId('all');
- setSelectedBrand('All');
- setSelectedVendor('All');
- setSearchTerm('');
- setPriceRange('');
- setSelectedRestriction('');
- };
-
- const hasActiveFilters = selectedCategoryId !== 'all' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
-
- // RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
- const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
- const flags: string[] = [];
- if (restrictions?.nfa) flags.push('NFA');
- if (restrictions?.sbr) flags.push('SBR');
- if (restrictions?.suppressor) flags.push('Suppressor');
- if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('State Restrictions');
- return flags;
- };
-
- const handleAdd = (part: Product) => {
- setAddedPartIds((prev) => [...prev, part.id]);
- setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
- };
-
- useEffect(() => {
- if (products.length) {
- const uniqueCategories = Array.from(new Set(products.map(p => p.category?.name)));
- const uniqueSubcategories = Array.from(new Set(products.map(p => p.subcategory)));
- console.log('Unique categories:', uniqueCategories);
- console.log('Unique subcategories:', uniqueSubcategories);
- }
- }, [products]);
+ // Reset to page 1 when filters change
+ useEffect(() => { setPage(1); }, [brand, department, category, subcategory, limit]);
return (
-
- {/* Page Title */}
-
-
-
- Parts Catalog
- {selectedCategory && selectedCategoryId !== 'all' && (
-
- - {selectedCategory.name}
-
- )}
-
-
- {selectedCategory && selectedCategoryId !== 'all'
- ? `Showing ${selectedCategory.name} parts for your build`
- : 'Browse and filter firearm parts for your build'
- }
-
-
-
-
- {/* Search and Filters */}
-
-
- {/* Search Row */}
-
-
- {isSearchExpanded ? (
-
-
-
-
-
{
- setIsSearchExpanded(false);
- setSearchTerm('');
- }}
- className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors flex-shrink-0"
- aria-label="Close search"
- >
-
-
-
- ) : (
-
setIsSearchExpanded(true)}
- className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors rounded-lg hover:bg-zinc-100"
- aria-label="Open search"
- >
-
-
- )}
-
-
-
- {/* Filters Row */}
-
- {/* Category Dropdown */}
-
- ({ value: c.id, label: c.name }))}
- placeholder="All categories"
- />
-
-
- {/* Subcategory Dropdown (only if a category is selected) */}
- {selectedCategory && selectedCategoryId !== 'all' && (
-
-
({ value: s.id, label: s.name }))}
- placeholder="All subcategories"
- />
+ <>
+
+
+ {/* Sidebar Filters */}
+
+
+
+ Brand
+ setBrand(e.target.value)}>
+ All
+ {brandOptions.map(opt => {opt} )}
+
+
+
+ Department
+ setDepartment(e.target.value)}>
+ All
+ {departmentOptions.map(opt => {opt} )}
+
+
+
+ Category
+ setCategory(e.target.value)}>
+ All
+ {categoryOptions.map(opt => {opt} )}
+
+
+
+ Subcategory
+ setSubcategory(e.target.value)}>
+ All
+ {subcategoryOptions.map(opt => {opt} )}
+
- )}
-
- {/* Brand Dropdown */}
-
-
-
- {/* Vendor Dropdown */}
-
-
-
-
- {/* Price Range */}
-
-
-
-
- Price Range
-
-
-
- {priceRange === '' ? 'Select price range' :
- priceRange === 'under-100' ? 'Under $100' :
- priceRange === '100-300' ? '$100 - $300' :
- priceRange === '300-500' ? '$300 - $500' :
- priceRange === 'over-500' ? '$500+' : priceRange}
-
-
-
-
-
-
-
- {[
- { value: '', label: 'Select price range' },
- { value: 'under-100', label: 'Under $100' },
- { value: '100-300', label: '$100 - $300' },
- { value: '300-500', label: '$300 - $500' },
- { value: 'over-500', label: '$500+' }
- ].map((option, optionIdx) => (
-
- `relative cursor-default select-none py-2 pl-10 pr-4 ${
- active ? 'bg-blue-100 text-blue-900' : 'text-zinc-900'
- }`
- }
- value={option.value}
- >
- {({ selected }) => (
- <>
-
- {option.label}
-
- {selected ? (
-
-
-
- ) : null}
- >
- )}
-
- ))}
-
-
-
-
-
-
- {/* Restriction Filter */}
-
-
-
-
- {/* Clear Filters */}
-
-
-
- Clear All
-
-
-
-
-
-
- {/* Parts Display */}
-
- {/* View Toggle and Results Count */}
-
-
- {loading ? 'Loading...' : `Showing ${startIndex + 1}-${Math.min(endIndex, sortedParts.length)} of ${sortedParts.length} parts`}
- {hasActiveFilters && !loading && (
-
- (filtered)
-
- )}
- {error && {error} }
-
-
- {/* View Toggle */}
-
-
View:
-
-
setViewMode('table')}
- aria-label="Table view"
- >
-
-
-
setViewMode('cards')}
- aria-label="Card view"
- >
-
-
-
-
-
-
- {/* Table View */}
- {viewMode === 'table' && (
-
-
-
-
+
+ {/* Products Table */}
+
+
+
+
-
- Product
-
-
- Category
-
- handleSort('price')}
- >
-
- Price
- {getSortIcon('price')}
-
-
-
- Actions
-
+ {columns.map(col => (
+ {col}
+ ))}
-
- {paginatedParts.map((part) => (
-
-
-
- 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" />
-
-
-
- {part.name}
-
-
-
-
-
- {part.category.name}
-
-
-
-
- ${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
-
-
-
- {(() => {
- // Find if this part is already selected for any component
- const selectedComponentId = Object.entries(selectedParts).find(([_, selectedPart]) => selectedPart?.id === part.id)?.[0];
-
- // Find the appropriate component based on category match
- const matchingComponentName = getMatchingComponentName(part.category.name);
- const matchingComponent = (buildGroups as {components: any[]}[]).flatMap((group) => group.components).find((component: any) =>
- component.name === matchingComponentName && !selectedParts[component.id]
- );
-
- if (selectedComponentId) {
- return (
- removePartForComponent(selectedComponentId)}
- >
- Remove
-
- );
- } else if (matchingComponent && !selectedParts[matchingComponent.id]) {
- return (
- {
- selectPartForComponent(matchingComponent.id, {
- id: part.id,
- name: part.name,
- image_url: part.image_url,
- brand: part.brand,
- category: part.category,
- offers: part.offers,
- });
- router.push('/build');
- }}
- >
- +
- to build
-
- );
- } else {
- return (
- N/A
- );
- }
- })()}
-
-
- ))}
+
+ {loading ? (
+ Loading...
+ ) : paginatedProducts.length === 0 ? (
+ No products found.
+ ) : (
+ paginatedProducts.map((product, i) => (
+
+ {columns.map(col => (
+
+ {col === 'imageUrl' && product[col] ? (
+
+ ) : (
+ product[col] ?? ''
+ )}
+
+ ))}
+
+ ))
+ )}
-
-
- )}
-
- {/* Pagination */}
- {totalPages > 1 && (
- {
- setItemsPerPage(items);
- setCurrentPage(1); // Reset to first page when changing items per page
- }}
- totalItems={sortedParts.length}
- startIndex={startIndex}
- endIndex={endIndex}
- />
- )}
-
- {/* Card View */}
- {viewMode === 'cards' && (
-
- {paginatedParts.map((part) => (
-
handleAdd(part)} added={addedPartIds.includes(part.id)} />
- ))}
-
- )}
-
-
- {/* Compact Restriction Legend */}
-
-
-
Restrictions:
-
-
🔒NFA
-
National Firearms Act
-
-
-
📏SBR
-
Short Barrel Rifle
-
-
-
🔇Suppressor
-
Sound Suppressor
-
-
-
-
🗺️State
-
State Restrictions
-
-
-
🥁High Cap
-
High Capacity
-
-
-
🤝SilencerShop
-
SilencerShop Partner
-
+ {/* Pagination Controls */}
+
+ setPage(p => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >Prev
+ Page {page} of {totalPages || 1}
+ setPage(p => p + 1)}
+ disabled={page >= totalPages}
+ >Next
+ { setLimit(Number(e.target.value)); setPage(1); }}
+ >
+ {[25, 50, 100, 200].map(opt => (
+ {opt} / page
+ ))}
+
+ Total: {filteredProducts.length}
+
+
+
-
-
+
+ >
);
}
\ No newline at end of file
diff --git a/src/app/(main)/products/[slug]/page.tsx b/src/app/(main)/products/[slug]/page.tsx
index 7ed23d1..c726b91 100644
--- a/src/app/(main)/products/[slug]/page.tsx
+++ b/src/app/(main)/products/[slug]/page.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import RestrictionAlert from '@/components/RestrictionAlert';
-import { StarIcon } from '@heroicons/react/20/solid';
+import { StarIcon, HomeIcon } from '@heroicons/react/20/solid';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
@@ -82,6 +82,13 @@ export default function ProductDetailsPage() {
);
}
+ // Breadcrumb pages array
+ const pages = [
+ { name: 'Parts', href: '/parts', current: false },
+ { name: product.category.name, href: `/parts?category=${encodeURIComponent(product.category.name)}`, current: false },
+ { name: product.name, href: '#', current: true },
+ ];
+
const allImages = product.images && product.images.length > 0
? product.images
: [product.image_url];
@@ -126,14 +133,48 @@ export default function ProductDetailsPage() {
return (
{/* Breadcrumb */}
-
+
+
+
+
+
+ {pages.map((page, idx) => (
+
+
+
+
+
+ {page.current ? (
+
+ {page.name}
+
+ ) : (
+
+ {page.name}
+
+ )}
+
+
+ ))}
+
+
{/* Restriction Alert */}
{(product.restrictions?.nfa || product.restrictions?.sbr || product.restrictions?.suppressor) && (
@@ -213,7 +254,7 @@ export default function ProductDetailsPage() {
{/* Price Range */}
-
+
${lowestPrice.toFixed(2)}
{lowestPrice !== highestPrice && (
@@ -235,13 +276,16 @@ export default function ProductDetailsPage() {
{/* Add to Build Button */}
-
-
+
+
Add to Current Build
-
+
+
+
+
Save for Later
-
+
{addSuccess && (
Added to build!
@@ -278,7 +322,7 @@ export default function ProductDetailsPage() {
-
+
${offer.price.toFixed(2)}
{offer.inStock !== undefined && (
diff --git a/src/app/admin/AdminNavbar.tsx b/src/app/admin/AdminNavbar.tsx
index bee1cfd..6a2a00a 100644
--- a/src/app/admin/AdminNavbar.tsx
+++ b/src/app/admin/AdminNavbar.tsx
@@ -23,6 +23,7 @@ import {
HomeIcon,
UsersIcon,
XMarkIcon,
+ CubeIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
@@ -30,6 +31,7 @@ const navigation = [
{ name: 'Dashboard', href: '/admin', icon: HomeIcon },
{ name: 'Users', href: '/admin/users', icon: UsersIcon },
{ name: 'Category Mapping', href: '/admin/category-mapping', icon: ChartPieIcon },
+ { name: 'Products', href: '/admin/products', icon: CubeIcon },
// { name: 'Settings', href: '/admin/settings', icon: Cog6ToothIcon }, // optional/future
];
const userNavigation = [
diff --git a/src/app/admin/category-mapping/page.tsx b/src/app/admin/category-mapping/page.tsx
index f15fce2..b452a0f 100644
--- a/src/app/admin/category-mapping/page.tsx
+++ b/src/app/admin/category-mapping/page.tsx
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState, ChangeEvent, FormEvent } from "react";
+import CategoryTreeTest from '@/components/CategoryTreeTest';
type Mapping = {
id: number;
@@ -161,6 +162,7 @@ export default function CategoryMappingAdmin() {
))}
+
);
}
\ No newline at end of file
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 13d98bf..8a16f1a 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -83,7 +83,7 @@ export default function AdminDashboard() {
-
+
Total Users
@@ -126,7 +126,7 @@ export default function AdminDashboard() {
-
{stats.activeUsers}
+
{stats.activeUsers}
active users
diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx
new file mode 100644
index 0000000..bb2c2ae
--- /dev/null
+++ b/src/app/admin/products/page.tsx
@@ -0,0 +1,160 @@
+'use client';
+import { useEffect, useState, useMemo } from 'react';
+
+// Core columns for performance and usability
+const columns = [
+ 'uuid',
+ 'sku',
+ 'brandName',
+ 'productName',
+ 'department',
+ 'category',
+ 'subcategory',
+ 'retailPrice',
+ 'salePrice',
+ 'imageUrl',
+];
+
+export default function AdminProductsPage() {
+ const [products, setProducts] = useState
([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [limit, setLimit] = useState(50);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Filter state
+ const [brand, setBrand] = useState('');
+ const [department, setDepartment] = useState('');
+ const [category, setCategory] = useState('');
+ const [subcategory, setSubcategory] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setError(null);
+ fetch(`/api/products?page=${page}&limit=${limit}`)
+ .then(res => res.json())
+ .then(data => {
+ setProducts(data.data || []);
+ setTotal(data.total || 0);
+ setLoading(false);
+ })
+ .catch(err => {
+ setError(err.message || 'Error fetching products');
+ setLoading(false);
+ });
+ }, [page, limit]);
+
+ // Get unique filter options from current page's products
+ const brandOptions = useMemo(() => Array.from(new Set(products.map(p => p.brandName).filter(Boolean))).sort(), [products]);
+ const departmentOptions = useMemo(() => Array.from(new Set(products.map(p => p.department).filter(Boolean))).sort(), [products]);
+ const categoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.category).filter(Boolean))).sort(), [products]);
+ const subcategoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.subcategory).filter(Boolean))).sort(), [products]);
+
+ // Filter products before rendering
+ const filteredProducts = useMemo(() => {
+ return products.filter(p =>
+ (!brand || p.brandName === brand) &&
+ (!department || p.department === department) &&
+ (!category || p.category === category) &&
+ (!subcategory || p.subcategory === subcategory)
+ );
+ }, [products, brand, department, category, subcategory]);
+
+ // Reset to page 1 when filters change
+ useEffect(() => { setPage(1); }, [brand, department, category, subcategory]);
+
+ return (
+
+
Admin Products
+ {error &&
{error}
}
+ {/* Filters */}
+
+
+ Brand
+ setBrand(e.target.value)}>
+ All
+ {brandOptions.map(opt => {opt} )}
+
+
+
+ Department
+ setDepartment(e.target.value)}>
+ All
+ {departmentOptions.map(opt => {opt} )}
+
+
+
+ Category
+ setCategory(e.target.value)}>
+ All
+ {categoryOptions.map(opt => {opt} )}
+
+
+
+ Subcategory
+ setSubcategory(e.target.value)}>
+ All
+ {subcategoryOptions.map(opt => {opt} )}
+
+
+
+
+
+
+
+ {columns.map(col => (
+ {col}
+ ))}
+
+
+
+ {loading ? (
+ Loading...
+ ) : filteredProducts.length === 0 ? (
+ No products found.
+ ) : (
+ filteredProducts.map((product, i) => (
+
+ {columns.map(col => (
+
+ {col === 'imageUrl' && product[col] ? (
+
+ ) : (
+ product[col] ?? ''
+ )}
+
+ ))}
+
+ ))
+ )}
+
+
+
+ {/* Pagination Controls */}
+
+ setPage(p => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >Prev
+ Page {page} of {Math.ceil(total / limit) || 1}
+ setPage(p => p + 1)}
+ disabled={page * limit >= total}
+ >Next
+ { setLimit(Number(e.target.value)); setPage(1); }}
+ >
+ {[25, 50, 100, 200].map(opt => (
+ {opt} / page
+ ))}
+
+ Total: {total}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx
index 4ccae74..a729b74 100644
--- a/src/app/admin/users/page.tsx
+++ b/src/app/admin/users/page.tsx
@@ -47,7 +47,7 @@ export default async function AdminUsersPage() {
{/* Stats Cards */}
-
{usersList.length}
+
{usersList.length}
Total Users
diff --git a/src/app/api/product-categories/route.ts b/src/app/api/product-categories/route.ts
index 0c9dabf..adc7005 100644
--- a/src/app/api/product-categories/route.ts
+++ b/src/app/api/product-categories/route.ts
@@ -4,5 +4,23 @@ import { product_categories } from '@/db/schema';
export async function GET() {
const allCategories = await db.select().from(product_categories);
- return NextResponse.json({ success: true, data: allCategories });
+
+ // Build a map of id -> category object (with children array)
+ const categoryMap = Object.fromEntries(
+ allCategories.map(cat => [cat.id, { ...cat, children: [] as any[] }])
+ );
+
+ // Build the hierarchy
+ const rootCategories: any[] = [];
+ for (const cat of allCategories) {
+ if (cat.parent_category_id) {
+ if (categoryMap[cat.parent_category_id]) {
+ categoryMap[cat.parent_category_id].children.push(categoryMap[cat.id]);
+ }
+ } else {
+ rootCategories.push(categoryMap[cat.id]);
+ }
+ }
+
+ return NextResponse.json({ success: true, data: rootCategories });
}
\ No newline at end of file
diff --git a/src/app/api/products/route.ts b/src/app/api/products/route.ts
index bf4a5a2..cc25942 100644
--- a/src/app/api/products/route.ts
+++ b/src/app/api/products/route.ts
@@ -1,7 +1,34 @@
+import { db } from '@/db';
+import { bb_products } from '@/db/schema';
import { NextResponse } from 'next/server';
+import { sql } from 'drizzle-orm';
-export async function GET() {
- const res = await fetch('http://localhost:8080/api/products'); // <-- your Spring backend endpoint
- const data = await res.json();
- return NextResponse.json(data);
+function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/(^-|-$)+/g, '');
+}
+
+export async function GET(req: Request) {
+ try {
+ const { searchParams } = new URL(req.url);
+ const page = parseInt(searchParams.get('page') || '1', 10);
+ const limit = parseInt(searchParams.get('limit') || '50', 10);
+ const offset = (page - 1) * limit;
+
+ // Get total count using raw SQL
+ const totalResult = await db.execute(sql`SELECT COUNT(*)::int AS count FROM bb_products`);
+ const total = Number(totalResult.rows?.[0]?.count || 0);
+
+ // Get paginated products
+ const allProducts = await db.select().from(bb_products).limit(limit).offset(offset);
+ const mapped = allProducts.map((item: any) => ({
+ ...item,
+ slug: slugify(item.productName || item.product_name || item.name || ''),
+ }));
+ return NextResponse.json({ success: true, data: mapped, total });
+ } catch (error) {
+ return NextResponse.json({ success: false, error: String(error) }, { status: 500 });
+ }
}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7365e29..cb58af8 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -15,7 +15,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
+
{children}
diff --git a/src/components/CategoryTreeTest.tsx b/src/components/CategoryTreeTest.tsx
new file mode 100644
index 0000000..1580c54
--- /dev/null
+++ b/src/components/CategoryTreeTest.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useState } from "react";
+
+function CategoryTree({ categories }: { categories: any[] }) {
+ if (!categories || categories.length === 0) return null;
+ return (
+
+ {categories.map((cat) => (
+
+ {cat.name}
+ {cat.children && cat.children.length > 0 && (
+
+ )}
+
+ ))}
+
+ );
+}
+
+export default function CategoryTreeTest() {
+ const [categories, setCategories] = useState
([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch("/api/product-categories")
+ .then((res) => res.json())
+ .then((data) => {
+ setCategories(data.data);
+ setLoading(false);
+ });
+ }, []);
+
+ if (loading) return Loading categories...
;
+
+ return (
+
+
Product Category Hierarchy
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index a84ca06..b416625 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -75,7 +75,7 @@ export default function Navbar() {
<>
{/* Admin Banner - Moved to top */}
{session?.user && (session.user as any)?.isAdmin && (
-
+
@@ -92,13 +92,13 @@ export default function Navbar() {
)}
{/* Top Bar */}
-
-
Pew Builder
+
+
Pew Builder
{loading ? null : session?.user ? (
<>
setMenuOpen((v) => !v)}
@@ -177,21 +177,14 @@ export default function Navbar() {
href={item.href}
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
pathname === item.href
- ? 'text-blue-600 font-semibold underline underline-offset-4'
- : 'text-neutral-700 dark:text-neutral-200 hover:text-blue-600'
+ ? 'text-primary font-semibold underline underline-offset-4'
+ : 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
}`}
>
{item.label}
))}
-
- {/* Right: Search */}
-
-
-
-
-
>
diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx
index 1e6f3c0..2f34945 100644
--- a/src/components/ProductCard.tsx
+++ b/src/components/ProductCard.tsx
@@ -108,7 +108,7 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
{product.brand.name}
- ${lowestPrice.toFixed(2)}
+ ${lowestPrice.toFixed(2)}
diff --git a/tailwind.config.js b/tailwind.config.js
index 490a7c7..87fc8fd 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,8 +1,28 @@
module.exports = {
+ darkMode: 'class',
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
- theme: { extend: {} },
+ theme: {
+ extend: {
+ colors: {
+ primary: "oklch(45% 0.124 130.933)",
+ secondary: "oklch(37% 0.034 259.733)",
+ accent: {
+ 100: "oklch(95% 0.013 255.508)",
+ 500: "oklch(70% 0.013 255.508)",
+ 600: "oklch(60% 0.013 255.508)",
+ 700: "oklch(50% 0.013 255.508)",
+ },
+ neutral: "oklch(14% 0.005 285.823)",
+ base: {
+ 100: "oklch(100% 0 0)",
+ 200: "oklch(98% 0 0)",
+ 300: "oklch(95% 0 0)",
+ },
+ },
+ },
+ },
plugins: [],
}