clean up stuff

This commit is contained in:
2025-07-02 04:16:53 -04:00
parent f43f68709a
commit bdb5e0fe55
22 changed files with 740 additions and 680 deletions

View File

@@ -17,7 +17,7 @@ export default function ForgotPasswordPage() {
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1> <h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm"> <p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
Enter your email address and we'll send you a link to reset your password.<br/> Enter your email address and we'll send you a link to reset your password.<br/>
<span className="text-blue-600 font-semibold">(This feature is not yet implemented.)</span> <span className="text-primary font-semibold">(This feature is not yet implemented.)</span>
</p> </p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<input <input
@@ -38,7 +38,7 @@ export default function ForgotPasswordPage() {
</button> </button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<Link href="/account/login" className="text-blue-600 hover:underline text-sm">Back to login</Link> <Link href="/account/login" className="text-primary hover:underline text-sm">Back to login</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -55,7 +55,7 @@ export default function LoginPage() {
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2> <h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300"> <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Or{' '} Or{' '}
<Link href="/account/register" className="font-medium text-blue-600 hover:text-blue-500"> <Link href="/account/register" className="font-medium text-primary hover:text-blue-500">
Sign Up For Free Sign Up For Free
</Link> </Link>
</p> </p>
@@ -109,7 +109,7 @@ export default function LoginPage() {
id="remember-me" id="remember-me"
name="remember-me" name="remember-me"
type="checkbox" type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" className="h-4 w-4 text-primary focus:ring-blue-500 border-gray-300 rounded"
disabled={loading} disabled={loading}
/> />
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300"> <label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
@@ -118,7 +118,7 @@ export default function LoginPage() {
</div> </div>
<div className="text-sm"> <div className="text-sm">
<Link href="/account/forgot-password" className="font-medium text-blue-600 hover:text-blue-500"> <Link href="/account/forgot-password" className="font-medium text-primary hover:text-blue-500">
Forgot your password? Forgot your password?
</Link> </Link>
</div> </div>

View File

@@ -86,7 +86,7 @@ export default function RegisterPage() {
</button> </button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<Link href="/account/login" className="text-blue-600 hover:underline text-sm">Already have an account? Sign in</Link> <Link href="/account/login" className="text-primary hover:underline text-sm">Already have an account? Sign in</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -261,6 +261,9 @@ const getProductCategoryForComponent = (componentName: string): string => {
return componentMap[componentName] || 'Lower Receiver'; return componentMap[componentName] || 'Lower Receiver';
}; };
// Add a slugify helper at the top of the file
const slugify = (str: string) => str?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
export { buildGroups }; export { buildGroups };
export default function BuildPage() { export default function BuildPage() {
const [sortField, setSortField] = useState<SortField>('name'); const [sortField, setSortField] = useState<SortField>('name');
@@ -404,7 +407,7 @@ export default function BuildPage() {
</div> </div>
<div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2"> <div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
<div> <div>
<div className="text-2xl font-bold text-blue-600">${actualTotalCost.toFixed(2)}</div> <div className="text-2xl font-bold text-primary">${actualTotalCost.toFixed(2)}</div>
<div className="text-sm text-gray-500">Total Cost</div> <div className="text-sm text-gray-500">Total Cost</div>
</div> </div>
<button <button
@@ -616,9 +619,6 @@ export default function BuildPage() {
<span className="text-sm">{getSortIcon('estimatedPrice')}</span> <span className="text-sm">{getSortIcon('estimatedPrice')}</span>
</div> </div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Notes
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Selected Product Selected Product
</th> </th>
@@ -638,7 +638,7 @@ export default function BuildPage() {
<React.Fragment key={group.name}> <React.Fragment key={group.name}>
{/* Group Header */} {/* Group Header */}
<tr className="bg-gray-100"> <tr className="bg-gray-100">
<td colSpan={7} className="px-6 py-2"> <td colSpan={5} className="px-6 py-2">
<div className="flex items-center"> <div className="flex items-center">
<div> <div>
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3> <h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
@@ -673,7 +673,7 @@ export default function BuildPage() {
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
<Link <Link
href={`/products/${selected.id}`} href={`/products/${selected.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline" className="text-primary hover:text-blue-800 hover:underline"
> >
{selected.name} {selected.name}
</Link> </Link>
@@ -709,11 +709,6 @@ export default function BuildPage() {
</div> </div>
)} )}
</td> </td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500 max-w-xs">
{component.notes}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{selected ? ( {selected ? (
<button <button
@@ -724,7 +719,7 @@ export default function BuildPage() {
</button> </button>
) : ( ) : (
<Link <Link
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`} href={`/parts/${slugify(getProductCategoryForComponent(component.name))}`}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors" className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors"
> >
Find Parts Find Parts
@@ -739,7 +734,7 @@ export default function BuildPage() {
}) })
) : ( ) : (
<tr> <tr>
<td colSpan={7} className="px-6 py-12 text-center"> <td colSpan={5} className="px-6 py-12 text-center">
<div className="text-gray-500"> <div className="text-gray-500">
<div className="text-lg font-medium mb-2">No components found</div> <div className="text-lg font-medium mb-2">No components found</div>
<div className="text-sm">Try adjusting your filters or search terms</div> <div className="text-sm">Try adjusting your filters or search terms</div>
@@ -757,7 +752,7 @@ export default function BuildPage() {
<div className="text-sm text-gray-700"> <div className="text-sm text-gray-700">
Showing {sortedComponents.length} of {allComponents.length} components Showing {sortedComponents.length} of {allComponents.length} components
{hasActiveFilters && ( {hasActiveFilters && (
<span className="ml-2 text-blue-600"> <span className="ml-2 text-primary">
(filtered) (filtered)
</span> </span>
)} )}

View File

@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
<div className="text-sm text-gray-500">Completed</div> <div className="text-sm text-gray-500">Completed</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div> <div className="text-2xl font-bold text-primary">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div> <div className="text-sm text-gray-500">In Progress</div>
</div> </div>
<div className="text-center"> <div className="text-center">

View File

@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
<div className="text-sm text-gray-500">Completed</div> <div className="text-sm text-gray-500">Completed</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div> <div className="text-2xl font-bold text-primary">{inProgressMyBuilds}</div>
<div className="text-sm text-gray-500">In Progress</div> <div className="text-sm text-gray-500">In Progress</div>
</div> </div>
<div className="text-center"> <div className="text-center">

View File

@@ -34,7 +34,7 @@ export default function LandingPage() {
<div className="mt-10 flex items-top gap-x-6"> <div className="mt-10 flex items-top gap-x-6">
<Link <Link
href="/build" href="/build"
className="bg-blue-600 hover:bg-blue-700 text-white text-base font-semibold px-6 py-3 rounded-md transition-colors" className="bg-gray-900 hover:bg-gray-950 text-white text-base font-semibold px-6 py-3 transition-colors"
> >
Get Building Get Building
</Link> </Link>
@@ -52,6 +52,7 @@ export default function LandingPage() {
</div> </div>
</div> </div>
{/* Beta Tester CTA */} {/* Beta Tester CTA */}
<BetaTester /> <BetaTester />
</div> </div>
); );

View File

@@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="max-w-7xl mx-auto px-4 py-8 text-red-600 font-bold">Category not found.</div>;
}
return (
<main className="min-h-screen bg-zinc-50">
<div className="max-w-7xl mx-0 px-4 sm:px-6 lg:px-8 py-8 flex flex-col md:flex-row gap-8">
{/* Debug block */}
<div className="mb-4 p-2 bg-yellow-100 text-xs text-yellow-900 rounded">
<div>categoryParam: <b>{categoryParam}</b></div>
<div>First 5 categories in products: {[...new Set(products.map(p => p.category))].slice(0,5).join(', ')}</div>
</div>
{/* Sidebar Filters */}
<aside className="w-full md:w-64 flex-shrink-0 md:sticky md:top-8 z-10 bg-white border border-zinc-200 rounded-lg p-4 h-fit mb-6 md:mb-0">
<div className="space-y-6">
<div>
<label className="block text-xs font-semibold mb-1">Brand</label>
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={brand} onChange={e => setBrand(e.target.value)}>
<option value="">All</option>
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold mb-1">Department</label>
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={department} onChange={e => setDepartment(e.target.value)}>
<option value="">All</option>
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold mb-1">Subcategory</label>
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
<option value="">All</option>
{subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
</div>
</aside>
{/* Products Table */}
<section className="flex-1">
<h1 className="text-2xl font-bold mb-4 capitalize">{categoryParam} Parts</h1>
<div className="overflow-x-auto border rounded bg-white">
<table className="min-w-full text-sm">
<thead>
<tr>
{columns.map(col => (
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
) : paginatedProducts.length === 0 ? (
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
) : (
paginatedProducts.map((product, i) => (
<tr key={product.uuid || i} className="border-b hover:bg-zinc-50">
{columns.map(col => (
<td key={col} className="px-2 py-1 max-w-xs truncate">
{col === 'imageUrl' && product[col] ? (
<img src={product[col]} alt="thumb" className="h-10 w-10 object-contain border rounded" />
) : (
product[col] ?? ''
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="flex items-center gap-4 mt-4">
<button
className="px-3 py-1 border rounded disabled:opacity-50"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>Prev</button>
<span>Page {page} of {totalPages || 1}</span>
<button
className="px-3 py-1 border rounded disabled:opacity-50"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages}
>Next</button>
<select
className="ml-4 border rounded px-2 py-1"
value={limit}
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
>
{[25, 50, 100, 200].map(opt => (
<option key={opt} value={opt}>{opt} / page</option>
))}
</select>
<span className="ml-2 text-zinc-500">Total: {filteredProducts.length}</span>
</div>
</section>
</div>
</main>
);
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { Listbox, Transition } from '@headlessui/react'; import { Listbox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'; 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 Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore'; import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page'; import { buildGroups } from '../build/page';
import { categoryToComponentType, standardizedComponentTypes, mapToBuilderType, builderCategories, subcategoryMapping } from './categoryMapping';
// Product type (copied from mock/product for type safety) // Product type (copied from mock/product for type safety)
type Product = { type Product = {
@@ -170,7 +169,7 @@ const Dropdown = ({
{option.label} {option.label}
</span> </span>
{selected ? ( {selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600"> <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
<CheckIcon className="h-4 w-4" aria-hidden="true" /> <CheckIcon className="h-4 w-4" aria-hidden="true" />
</span> </span>
) : null} ) : null}
@@ -222,11 +221,6 @@ const getComponentCategory = (productCategory: string): string => {
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match 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 // Pagination Component
const Pagination = ({ const Pagination = ({
currentPage, currentPage,
@@ -344,640 +338,218 @@ const Pagination = ({
); );
}; };
export default function Home() { // --- Canonical Category Fetch ---
const searchParams = useSearchParams(); const useCanonicalCategories = () => {
const router = useRouter(); const [categories, setCategories] = useState<any[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
const [addedPartIds, setAddedPartIds] = useState<string[]>([]);
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(() => { useEffect(() => {
setLoading(true); fetch('/api/product-categories')
fetch('/api/test-products')
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.success && Array.isArray(data.data)) { setCategories(data.data);
// Map API data to Product type setLoading(false);
const mapped: Product[] = data.data.slice(0, 50).map((item: any) => ({ });
id: item.uuid, }, []);
name: item.productName, return { categories, loading };
description: item.shortDescription || item.longDescription || '', };
longDescription: item.longDescription,
image_url: item.imageUrl || item.thumbUrl || '/window.svg', // --- Flatten category tree for dropdowns ---
images: [item.imageUrl, item.thumbUrl].filter(Boolean), function flattenCategories(categories: any[], parent: any = null, depth = 0): any[] {
brand: { let result: any[] = [];
id: item.brandName || 'unknown', for (const cat of categories) {
name: item.brandName || 'Unknown', result.push({ ...cat, depth, parent });
logo: item.brandLogoImage || '', if (cat.children && cat.children.length > 0) {
}, result = result.concat(flattenCategories(cat.children, cat, depth + 1));
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');
} }
}
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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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); setLoading(false);
}) })
.catch(err => { .catch(err => {
setError(String(err)); setError(err.message || 'Error fetching products');
setLoading(false); setLoading(false);
}); });
}, []); }, []);
// Extract unique values for dropdowns from live data // Get unique filter options from all products
const categories = [{ id: 'all', name: 'All Categories' }, ...builderCategories.map(cat => ({ id: cat.id, name: cat.name }))]; const brandOptions = useMemo(() => Array.from(new Set(products.map(p => p.brandName).filter(Boolean))).sort(), [products]);
const brands = [{ value: 'All', label: 'All Brands' }, ...Array.from(new Set(products.map(part => part.brand.name))).map(name => ({ value: name, label: name }))]; const departmentOptions = useMemo(() => Array.from(new Set(products.map(p => p.department).filter(Boolean))).sort(), [products]);
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 }))]; 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 // Filter products before rendering
useEffect(() => { const filteredProducts = useMemo(() => {
const categoryParam = searchParams.get('category'); return products.filter(p =>
if (categoryParam && categories.some(c => c.id === categoryParam)) { (!brand || p.brandName === brand) &&
setSelectedCategoryId(categoryParam); (!department || p.department === department) &&
} (!category || p.category === category) &&
// eslint-disable-next-line react-hooks/exhaustive-deps (!subcategory || p.subcategory === subcategory)
}, [searchParams, categories.map(c => c.id).join(',')]); );
}, [products, brand, department, category, subcategory]);
const selectedCategory = builderCategories.find(cat => cat.id === selectedCategoryId); // Pagination
const subcategoryOptions = selectedCategory const totalPages = Math.ceil(filteredProducts.length / limit);
? [{ id: 'all', name: 'All Subcategories' }, ...selectedCategory.subcategories] const paginatedProducts = filteredProducts.slice((page - 1) * limit, page * limit);
: [];
// Filter parts based on selected criteria // Reset to page 1 when filters change
const filteredParts = products.filter(part => { useEffect(() => { setPage(1); }, [brand, department, category, subcategory, limit]);
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]);
return ( return (
<main className="min-h-screen bg-zinc-50">
{/* Page Title */}
<div className="bg-white border-b border-zinc-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-zinc-900">
Parts Catalog
{selectedCategory && selectedCategoryId !== 'all' && (
<span className="text-primary-600 ml-2 text-2xl">
- {selectedCategory.name}
</span>
)}
</h1>
<p className="text-zinc-600 mt-2">
{selectedCategory && selectedCategoryId !== 'all'
? `Showing ${selectedCategory.name} parts for your build`
: 'Browse and filter firearm parts for your build'
}
</p>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white border-b border-zinc-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
{/* Search Row */}
<div className="mb-3 flex justify-end">
<div className={`transition-all duration-300 ease-in-out flex justify-end ${isSearchExpanded ? 'w-1/2' : 'w-auto'}`}>
{isSearchExpanded ? (
<div className="flex items-center gap-2 w-full justify-end">
<div className="flex-1 max-w-md">
<SearchInput
label=""
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search parts..."
/>
</div>
<button
onClick={() => {
setIsSearchExpanded(false);
setSearchTerm('');
}}
className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors flex-shrink-0"
aria-label="Close search"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
) : (
<button
onClick={() => setIsSearchExpanded(true)}
className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors rounded-lg hover:bg-zinc-100"
aria-label="Open search"
>
<MagnifyingGlassIcon className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Filters Row */}
<div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
{/* Category Dropdown */}
<div className="col-span-1">
<Dropdown
label="Category"
value={selectedCategoryId}
onChange={setSelectedCategoryId}
options={categories.map(c => ({ value: c.id, label: c.name }))}
placeholder="All categories"
/>
</div>
{/* Subcategory Dropdown (only if a category is selected) */}
{selectedCategory && selectedCategoryId !== 'all' && (
<div className="col-span-1">
<Dropdown
label="Subcategory"
value={selectedSubcategoryId}
onChange={setSelectedSubcategoryId}
options={subcategoryOptions.map(s => ({ value: s.id, label: s.name }))}
placeholder="All subcategories"
/>
</div>
)}
{/* Brand Dropdown */}
<div className="col-span-1">
<Dropdown
label="Brand"
value={selectedBrand}
onChange={setSelectedBrand}
options={brands}
placeholder="All brands"
/>
</div>
{/* Vendor Dropdown */}
<div className="col-span-1">
<Dropdown
label="Vendor"
value={selectedVendor}
onChange={setSelectedVendor}
options={vendors}
placeholder="All vendors"
/>
</div>
{/* Price Range */}
<div className="col-span-1">
<Listbox value={priceRange} onChange={setPriceRange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-zinc-700 mb-1">
Price Range
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-zinc-300 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-zinc-900">
{priceRange === '' ? 'Select price range' :
priceRange === 'under-100' ? 'Under $100' :
priceRange === '100-300' ? '$100 - $300' :
priceRange === '300-500' ? '$300 - $500' :
priceRange === 'over-500' ? '$500+' : priceRange}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<Listbox.Options>
{[
{ 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) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`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 }) => (
<> <>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}> <main className="min-h-screen bg-zinc-50">
{option.label} <div className="max-w-full mx-0 px-4 sm:px-6 lg:px-8 py-8 flex flex-col md:flex-row gap-8">
</span> {/* Sidebar Filters */}
{selected ? ( <aside className="w-full md:w-64 flex-shrink-0 md:sticky md:top-8 z-10 bg-white border border-zinc-200 rounded-lg p-4 h-fit mb-6 md:mb-0">
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600"> <div className="space-y-6">
<CheckIcon className="h-4 w-4" aria-hidden="true" /> <div>
</span> <label className="block text-xs font-semibold mb-1">Brand</label>
) : null} <select className="border rounded px-2 py-1 min-w-[120px] w-full" value={brand} onChange={e => setBrand(e.target.value)}>
</> <option value="">All</option>
)} {brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</Listbox.Option> </select>
))}
</Listbox.Options>
</Transition>
</div> </div>
</Listbox> <div>
<label className="block text-xs font-semibold mb-1">Department</label>
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={department} onChange={e => setDepartment(e.target.value)}>
<option value="">All</option>
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div> </div>
<div>
{/* Restriction Filter */} <label className="block text-xs font-semibold mb-1">Category</label>
<div className="col-span-1"> <select className="border rounded px-2 py-1 min-w-[120px] w-full" value={category} onChange={e => setCategory(e.target.value)}>
<Dropdown <option value="">All</option>
label="Restriction" {categoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
value={selectedRestriction} </select>
onChange={setSelectedRestriction}
options={restrictionOptions}
placeholder="All restrictions"
/>
</div> </div>
<div>
{/* Clear Filters */} <label className="block text-xs font-semibold mb-1">Subcategory</label>
<div className="col-span-1 flex items-end"> <select className="border rounded px-2 py-1 min-w-[120px] w-full" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
<button <option value="">All</option>
onClick={clearFilters} {subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
disabled={!hasActiveFilters} </select>
className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
hasActiveFilters
? 'bg-accent-600 hover:bg-accent-700 text-white'
: 'bg-zinc-200 text-zinc-400 cursor-not-allowed'
}`}
>
<XMarkIcon className="h-3.5 w-3.5" />
Clear All
</button>
</div> </div>
</div> </div>
</div> </aside>
</div> {/* Products Table */}
<section className="flex-1">
{/* Parts Display */} <div className="overflow-x-auto border rounded bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <table className="min-w-full text-sm">
{/* View Toggle and Results Count */} <thead>
<div className="flex justify-between items-center mb-6">
<div className="text-sm text-zinc-700">
{loading ? 'Loading...' : `Showing ${startIndex + 1}-${Math.min(endIndex, sortedParts.length)} of ${sortedParts.length} parts`}
{hasActiveFilters && !loading && (
<span className="ml-2 text-blue-600">
(filtered)
</span>
)}
{error && <span className="ml-2 text-red-500">{error}</span>}
</div>
{/* View Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-600">View:</span>
<div className="flex">
<button
className={`px-3 py-1 text-sm border rounded-l ${viewMode === 'table' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 hover:bg-gray-50'}`}
onClick={() => setViewMode('table')}
aria-label="Table view"
>
<TableCellsIcon className="h-5 w-5" />
</button>
<button
className={`px-3 py-1 text-sm border border-l-0 rounded-r ${viewMode === 'cards' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 hover:bg-gray-50'}`}
onClick={() => setViewMode('cards')}
aria-label="Card view"
>
<Squares2X2Icon className="h-5 w-5" />
</button>
</div>
</div>
</div>
{/* Table View */}
{viewMode === 'table' && (
<div className="bg-white shadow-sm rounded-lg overflow-hidden border border-zinc-200">
<div className="overflow-x-auto max-h-screen overflow-y-auto">
<table className="min-w-full divide-y divide-zinc-200">
<thead className="bg-zinc-50 sticky top-0 z-10 shadow-sm">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider"> {columns.map(col => (
Product <th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
</th> ))}
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
Category
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:bg-zinc-100"
onClick={() => handleSort('price')}
>
<div className="flex items-center space-x-1">
<span>Price</span>
<span className="text-sm">{getSortIcon('price')}</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-zinc-200"> <tbody>
{paginatedParts.map((part) => ( {loading ? (
<tr key={part.id} className="hover:bg-zinc-50 transition-colors"> <tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
<td className="px-0 py-2 flex items-center gap-2 align-top"> ) : paginatedProducts.length === 0 ? (
<div className="w-12 h-12 flex-shrink-0 rounded bg-zinc-100 overflow-hidden flex items-center justify-center border border-zinc-200"> <tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
<Image src={Array.isArray(part.images) && (part.images as string[]).length > 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" /> ) : (
</div> paginatedProducts.map((product, i) => (
<div className="max-w-md break-words whitespace-normal"> <tr key={product.uuid || i} className="border-b hover:bg-zinc-50">
<Link href={`/products/${part.slug}`} className="text-sm font-semibold text-blue-600 hover:underline"> {columns.map(col => (
{part.name} <td key={col} className="px-2 py-1 max-w-xs truncate">
</Link> {col === 'imageUrl' && product[col] ? (
</div> <img src={product[col]} alt="thumb" className="h-10 w-10 object-contain border rounded" />
) : (
product[col] ?? ''
)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{part.category.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-zinc-900">
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{(() => {
// 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 (
<button
className="border border-gray-300 hover:bg-gray-50 text-gray-700 px-3 py-1 rounded-md text-sm font-medium transition-colors"
onClick={() => removePartForComponent(selectedComponentId)}
>
Remove
</button>
);
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
return (
<button
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors flex items-center gap-1"
onClick={() => {
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');
}}
>
<span className="text-lg leading-none">+</span>
<span className="text-xs font-normal">to build</span>
</button>
);
} else {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-200 text-gray-500 text-xs">N/A</span>
);
}
})()}
</td>
</tr>
))} ))}
</tr>
))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination Controls */}
</div> <div className="flex items-center gap-4 mt-4">
)} <button
className="px-3 py-1 border rounded disabled:opacity-50"
{/* Pagination */} onClick={() => setPage(p => Math.max(1, p - 1))}
{totalPages > 1 && ( disabled={page === 1}
<Pagination >Prev</button>
currentPage={currentPage} <span>Page {page} of {totalPages || 1}</span>
totalPages={totalPages} <button
onPageChange={setCurrentPage} className="px-3 py-1 border rounded disabled:opacity-50"
itemsPerPage={itemsPerPage} onClick={() => setPage(p => p + 1)}
onItemsPerPageChange={(items) => { disabled={page >= totalPages}
setItemsPerPage(items); >Next</button>
setCurrentPage(1); // Reset to first page when changing items per page <select
}} className="ml-4 border rounded px-2 py-1"
totalItems={sortedParts.length} value={limit}
startIndex={startIndex} onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
endIndex={endIndex} >
/> {[25, 50, 100, 200].map(opt => (
)} <option key={opt} value={opt}>{opt} / page</option>
{/* Card View */}
{viewMode === 'cards' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{paginatedParts.map((part) => (
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
))} ))}
</select>
<span className="ml-2 text-zinc-500">Total: {filteredProducts.length}</span>
</div> </div>
)} </section>
</div>
{/* Compact Restriction Legend */}
<div className="mt-8 pt-4 border-t border-zinc-200">
<div className="flex items-center justify-center gap-4 text-xs text-zinc-500">
<span className="font-medium">Restrictions:</span>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-600 text-white">🔒NFA</div>
<span>National Firearms Act</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-600 text-white">📏SBR</div>
<span>Short Barrel Rifle</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-purple-600 text-white">🔇Suppressor</div>
<span>Sound Suppressor</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-600 text-white">🏪FFL</div>
<span>FFL Required</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-600 text-black">🗺State</div>
<span>State Restrictions</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-600 text-white">🥁High Cap</div>
<span>High Capacity</span>
</div>
<div className="flex items-center gap-1">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-green-600 text-white">🤝SilencerShop</div>
<span>SilencerShop Partner</span>
</div>
</div>
</div> </div>
</main> </main>
</>
); );
} }

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import RestrictionAlert from '@/components/RestrictionAlert'; 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 Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore'; 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 const allImages = product.images && product.images.length > 0
? product.images ? product.images
: [product.image_url]; : [product.image_url];
@@ -126,14 +133,48 @@ export default function ProductDetailsPage() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="text-sm breadcrumbs mb-6"> <nav aria-label="Breadcrumb" className="flex mb-4">
<ul> <ol role="list" className="flex space-x-2 rounded px-2 py-1 ">
<li><a href="/">Home</a></li> <li className="flex">
<li><a href="/parts">Parts</a></li> <div className="flex items-center">
<li><a href={`/parts?category=${product.category.name}`}>{product.category.name}</a></li> <a href="/" className="text-zinc-900 hover:text-primary">
<li>{product.name}</li> <HomeIcon aria-hidden="true" className="w-4 h-4 shrink-0" />
</ul> <span className="sr-only">Home</span>
</a>
</div> </div>
</li>
{pages.map((page, idx) => (
<li key={page.name} className="flex">
<div className="flex items-center">
<svg
fill="currentColor"
viewBox="0 0 24 44"
preserveAspectRatio="none"
aria-hidden="true"
className="h-4 w-4 shrink-0 text-zinc-500 dark:text-zinc-300"
>
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
</svg>
{page.current ? (
<span
aria-current="page"
className="ml-2 text-xs font-medium text-primary"
>
{page.name}
</span>
) : (
<a
href={page.href}
className="ml-2 text-xs font-medium text-zinc-500 hover:text-primary"
>
{page.name}
</a>
)}
</div>
</li>
))}
</ol>
</nav>
{/* Restriction Alert */} {/* Restriction Alert */}
{(product.restrictions?.nfa || product.restrictions?.sbr || product.restrictions?.suppressor) && ( {(product.restrictions?.nfa || product.restrictions?.sbr || product.restrictions?.suppressor) && (
@@ -213,7 +254,7 @@ export default function ProductDetailsPage() {
{/* Price Range */} {/* Price Range */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-3xl font-bold text-blue-600"> <div className="text-3xl font-bold text-primary">
${lowestPrice.toFixed(2)} ${lowestPrice.toFixed(2)}
</div> </div>
{lowestPrice !== highestPrice && ( {lowestPrice !== highestPrice && (
@@ -235,13 +276,16 @@ export default function ProductDetailsPage() {
</div> </div>
{/* Add to Build Button */} {/* Add to Build Button */}
<div className="flex gap-4"> <div className="flex gap-4 items-center">
<button className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors flex-1" onClick={handleAddToBuild}> <button className="bg-primary hover:bg-primary/80 text-white font-medium py-2 px-4 rounded-md transition-colors flex-1" onClick={handleAddToBuild}>
Add to Current Build Add to Current Build
</button> </button>
<button className="border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium py-2 px-4 rounded-md transition-colors"> <span className="flex items-center text-xs text-zinc-500 hover:text-primary cursor-pointer select-none">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4 mr-1">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75v-1.5A2.25 2.25 0 0015 3h-6a2.25 2.25 0 00-2.25 2.25v15l6-3 6 3v-7.5" />
</svg>
Save for Later Save for Later
</button> </span>
</div> </div>
{addSuccess && ( {addSuccess && (
<div className="mt-2 text-green-600 font-medium">Added to build!</div> <div className="mt-2 text-green-600 font-medium">Added to build!</div>
@@ -278,7 +322,7 @@ export default function ProductDetailsPage() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-primary">
${offer.price.toFixed(2)} ${offer.price.toFixed(2)}
</div> </div>
{offer.inStock !== undefined && ( {offer.inStock !== undefined && (

View File

@@ -23,6 +23,7 @@ import {
HomeIcon, HomeIcon,
UsersIcon, UsersIcon,
XMarkIcon, XMarkIcon,
CubeIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'; import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
@@ -30,6 +31,7 @@ const navigation = [
{ name: 'Dashboard', href: '/admin', icon: HomeIcon }, { name: 'Dashboard', href: '/admin', icon: HomeIcon },
{ name: 'Users', href: '/admin/users', icon: UsersIcon }, { name: 'Users', href: '/admin/users', icon: UsersIcon },
{ name: 'Category Mapping', href: '/admin/category-mapping', icon: ChartPieIcon }, { 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 // { name: 'Settings', href: '/admin/settings', icon: Cog6ToothIcon }, // optional/future
]; ];
const userNavigation = [ const userNavigation = [

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState, ChangeEvent, FormEvent } from "react"; import { useEffect, useState, ChangeEvent, FormEvent } from "react";
import CategoryTreeTest from '@/components/CategoryTreeTest';
type Mapping = { type Mapping = {
id: number; id: number;
@@ -161,6 +162,7 @@ export default function CategoryMappingAdmin() {
))} ))}
</tbody> </tbody>
</table> </table>
<CategoryTreeTest />
</div> </div>
); );
} }

View File

@@ -83,7 +83,7 @@ export default function AdminDashboard() {
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center"> <div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg"> <div className="p-2 bg-blue-100 rounded-lg">
<Users className="w-6 h-6 text-blue-600" /> <Users className="w-6 h-6 text-primary" />
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Users</p> <p className="text-sm font-medium text-gray-600">Total Users</p>
@@ -126,7 +126,7 @@ export default function AdminDashboard() {
</div> </div>
<div className="mt-4 flex items-center text-sm"> <div className="mt-4 flex items-center text-sm">
<Activity className="w-4 h-4 text-blue-500 mr-1" /> <Activity className="w-4 h-4 text-blue-500 mr-1" />
<span className="text-blue-600">{stats.activeUsers}</span> <span className="text-primary">{stats.activeUsers}</span>
<span className="text-gray-500 ml-1">active users</span> <span className="text-gray-500 ml-1">active users</span>
</div> </div>
</div> </div>

View File

@@ -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<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(50);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4">Admin Products</h1>
{error && <div className="text-red-600 mb-4">{error}</div>}
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-4 items-end">
<div>
<label className="block text-xs font-semibold mb-1">Brand</label>
<select className="border rounded px-2 py-1 min-w-[120px]" value={brand} onChange={e => setBrand(e.target.value)}>
<option value="">All</option>
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold mb-1">Department</label>
<select className="border rounded px-2 py-1 min-w-[120px]" value={department} onChange={e => setDepartment(e.target.value)}>
<option value="">All</option>
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold mb-1">Category</label>
<select className="border rounded px-2 py-1 min-w-[120px]" value={category} onChange={e => setCategory(e.target.value)}>
<option value="">All</option>
{categoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold mb-1">Subcategory</label>
<select className="border rounded px-2 py-1 min-w-[120px]" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
<option value="">All</option>
{subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
</div>
<div className="overflow-x-auto border rounded bg-white">
<table className="min-w-full text-sm">
<thead>
<tr>
{columns.map(col => (
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
) : filteredProducts.length === 0 ? (
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
) : (
filteredProducts.map((product, i) => (
<tr key={product.uuid || i} className="border-b hover:bg-zinc-50">
{columns.map(col => (
<td key={col} className="px-2 py-1 max-w-xs truncate">
{col === 'imageUrl' && product[col] ? (
<img src={product[col]} alt="thumb" className="h-10 w-10 object-contain border rounded" />
) : (
product[col] ?? ''
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="flex items-center gap-4 mt-4">
<button
className="px-3 py-1 border rounded disabled:opacity-50"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>Prev</button>
<span>Page {page} of {Math.ceil(total / limit) || 1}</span>
<button
className="px-3 py-1 border rounded disabled:opacity-50"
onClick={() => setPage(p => p + 1)}
disabled={page * limit >= total}
>Next</button>
<select
className="ml-4 border rounded px-2 py-1"
value={limit}
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
>
{[25, 50, 100, 200].map(opt => (
<option key={opt} value={opt}>{opt} / page</option>
))}
</select>
<span className="ml-2 text-zinc-500">Total: {total}</span>
</div>
</div>
);
}

View File

@@ -47,7 +47,7 @@ export default async function AdminUsersPage() {
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4"> <div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-blue-600">{usersList.length}</div> <div className="text-2xl font-bold text-primary">{usersList.length}</div>
<div className="text-sm text-gray-500">Total Users</div> <div className="text-sm text-gray-500">Total Users</div>
</div> </div>
<div className="bg-white rounded-lg shadow p-4"> <div className="bg-white rounded-lg shadow p-4">

View File

@@ -4,5 +4,23 @@ import { product_categories } from '@/db/schema';
export async function GET() { export async function GET() {
const allCategories = await db.select().from(product_categories); 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 });
} }

View File

@@ -1,7 +1,34 @@
import { db } from '@/db';
import { bb_products } from '@/db/schema';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { sql } from 'drizzle-orm';
export async function GET() { function slugify(name: string): string {
const res = await fetch('http://localhost:8080/api/products'); // <-- your Spring backend endpoint return name
const data = await res.json(); .toLowerCase()
return NextResponse.json(data); .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 });
}
} }

View File

@@ -15,7 +15,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en" suppressHydrationWarning data-theme="pew"> <html lang="en" suppressHydrationWarning>
<body className={`${inter.className} antialiased`}> <body className={`${inter.className} antialiased`}>
{children} {children}
</body> </body>

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
function CategoryTree({ categories }: { categories: any[] }) {
if (!categories || categories.length === 0) return null;
return (
<ul style={{ marginLeft: 16 }}>
{categories.map((cat) => (
<li key={cat.id}>
<span>{cat.name}</span>
{cat.children && cat.children.length > 0 && (
<CategoryTree categories={cat.children} />
)}
</li>
))}
</ul>
);
}
export default function CategoryTreeTest() {
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/product-categories")
.then((res) => res.json())
.then((data) => {
setCategories(data.data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading categories...</div>;
return (
<div>
<h2 className="text-lg font-bold mb-2">Product Category Hierarchy</h2>
<CategoryTree categories={categories} />
</div>
);
}

View File

@@ -75,7 +75,7 @@ export default function Navbar() {
<> <>
{/* Admin Banner - Moved to top */} {/* Admin Banner - Moved to top */}
{session?.user && (session.user as any)?.isAdmin && ( {session?.user && (session.user as any)?.isAdmin && (
<div className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 text-white py-2 px-4 sm:px-8 relative z-30"> <div className="w-full bg-gradient-to-r from-gray-600 to-gray-600 text-white py-2 px-4 sm:px-8 relative z-30">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<ShieldCheckIcon className="h-4 w-4" /> <ShieldCheckIcon className="h-4 w-4" />
@@ -92,13 +92,13 @@ export default function Navbar() {
)} )}
{/* Top Bar */} {/* Top Bar */}
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20"> <div className="w-full bg-[#4B6516] h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
<Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link> <Link href="/" className="font-bold text-lg tracking-tight text-white hover:underline focus:underline">Pew Builder</Link>
<div className="relative"> <div className="relative">
{loading ? null : session?.user ? ( {loading ? null : session?.user ? (
<> <>
<button <button
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold" className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold text-white"
aria-haspopup="true" aria-haspopup="true"
aria-expanded={menuOpen} aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)} onClick={() => setMenuOpen((v) => !v)}
@@ -177,21 +177,14 @@ export default function Navbar() {
href={item.href} href={item.href}
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${ className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
pathname === item.href pathname === item.href
? 'text-blue-600 font-semibold underline underline-offset-4' ? 'text-primary font-semibold underline underline-offset-4'
: 'text-neutral-700 dark:text-neutral-200 hover:text-blue-600' : 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
}`} }`}
> >
{item.label} {item.label}
</Link> </Link>
))} ))}
</div> </div>
{/* Right: Search */}
<div className="flex items-center space-x-4">
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
<MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
</button>
</div>
</div> </div>
</nav> </nav>
</> </>

View File

@@ -108,7 +108,7 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-500 dark:text-gray-400">{product.brand.name}</span> <span className="text-sm text-gray-500 dark:text-gray-400">{product.brand.name}</span>
<span className="text-lg font-bold text-blue-600 dark:text-blue-400">${lowestPrice.toFixed(2)}</span> <span className="text-lg font-bold text-primary dark:text-blue-400">${lowestPrice.toFixed(2)}</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -1,8 +1,28 @@
module.exports = { module.exports = {
darkMode: 'class',
content: [ content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{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: [], plugins: [],
} }