2025-06-29 13:43:46 -04:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
2025-06-29 15:58:03 -04:00
|
|
|
|
import { useSearchParams, useRouter } from 'next/navigation';
|
2025-06-29 13:43:46 -04:00
|
|
|
|
import { Listbox, Transition } from '@headlessui/react';
|
2025-06-29 15:58:03 -04:00
|
|
|
|
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
2025-06-29 13:43:46 -04:00
|
|
|
|
import SearchInput from '@/components/SearchInput';
|
|
|
|
|
|
import ProductCard from '@/components/ProductCard';
|
|
|
|
|
|
import RestrictionAlert from '@/components/RestrictionAlert';
|
|
|
|
|
|
import Tooltip from '@/components/Tooltip';
|
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
|
import { mockProducts } from '@/mock/product';
|
|
|
|
|
|
import type { Product } from '@/mock/product';
|
2025-06-29 14:07:26 -04:00
|
|
|
|
import Image from 'next/image';
|
2025-06-29 15:58:03 -04:00
|
|
|
|
import { useBuildStore } from '@/store/useBuildStore';
|
|
|
|
|
|
import { buildGroups } from '../build/page';
|
2025-06-29 13:43:46 -04:00
|
|
|
|
|
|
|
|
|
|
// Extract unique values for dropdowns
|
|
|
|
|
|
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
|
|
|
|
|
|
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))];
|
|
|
|
|
|
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
|
|
|
|
|
|
|
|
|
|
|
|
// Restrictions for filter dropdown
|
|
|
|
|
|
const restrictionOptions = [
|
2025-06-29 15:58:03 -04:00
|
|
|
|
'All',
|
2025-06-29 13:43:46 -04:00
|
|
|
|
'NFA',
|
|
|
|
|
|
'SBR',
|
2025-06-29 15:58:03 -04:00
|
|
|
|
'Suppressor',
|
|
|
|
|
|
'State Restrictions',
|
2025-06-29 13:43:46 -04:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
type SortField = 'name' | 'category' | 'price';
|
|
|
|
|
|
type SortDirection = 'asc' | 'desc';
|
|
|
|
|
|
|
|
|
|
|
|
// Restriction indicator component
|
|
|
|
|
|
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
|
|
|
|
|
|
const restrictionConfig = {
|
|
|
|
|
|
NFA: {
|
|
|
|
|
|
label: 'NFA',
|
|
|
|
|
|
color: 'bg-red-600 text-white',
|
|
|
|
|
|
icon: '🔒',
|
|
|
|
|
|
tooltip: 'National Firearms Act - Requires special registration'
|
|
|
|
|
|
},
|
|
|
|
|
|
SBR: {
|
|
|
|
|
|
label: 'SBR',
|
|
|
|
|
|
color: 'bg-orange-600 text-white',
|
|
|
|
|
|
icon: '📏',
|
|
|
|
|
|
tooltip: 'Short Barrel Rifle - Requires NFA registration'
|
|
|
|
|
|
},
|
|
|
|
|
|
SUPPRESSOR: {
|
|
|
|
|
|
label: 'Suppressor',
|
|
|
|
|
|
color: 'bg-purple-600 text-white',
|
|
|
|
|
|
icon: '🔇',
|
|
|
|
|
|
tooltip: 'Sound Suppressor - Requires NFA registration'
|
|
|
|
|
|
},
|
|
|
|
|
|
FFL_REQUIRED: {
|
|
|
|
|
|
label: 'FFL',
|
|
|
|
|
|
color: 'bg-blue-600 text-white',
|
|
|
|
|
|
icon: '🏪',
|
|
|
|
|
|
tooltip: 'Federal Firearms License required for purchase'
|
|
|
|
|
|
},
|
|
|
|
|
|
STATE_RESTRICTIONS: {
|
|
|
|
|
|
label: 'State',
|
|
|
|
|
|
color: 'bg-yellow-600 text-black',
|
|
|
|
|
|
icon: '🗺️',
|
|
|
|
|
|
tooltip: 'State-specific restrictions may apply'
|
|
|
|
|
|
},
|
|
|
|
|
|
HIGH_CAPACITY: {
|
|
|
|
|
|
label: 'High Cap',
|
|
|
|
|
|
color: 'bg-pink-600 text-white',
|
|
|
|
|
|
icon: '🥁',
|
|
|
|
|
|
tooltip: 'High capacity magazine - check local laws'
|
|
|
|
|
|
},
|
|
|
|
|
|
SILENCERSHOP_PARTNER: {
|
|
|
|
|
|
label: 'SilencerShop',
|
|
|
|
|
|
color: 'bg-green-600 text-white',
|
|
|
|
|
|
icon: '🤝',
|
|
|
|
|
|
tooltip: 'Available through SilencerShop partnership'
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
|
|
|
|
|
|
if (!config) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
|
|
|
|
|
|
title={config.tooltip}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{config.icon}</span>
|
|
|
|
|
|
<span>{config.label}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Tailwind UI Dropdown Component
|
|
|
|
|
|
const Dropdown = ({
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
options,
|
|
|
|
|
|
placeholder = "Select option"
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
|
options: string[];
|
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Listbox value={value} onChange={onChange}>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</Listbox.Label>
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
<span className="block truncate text-neutral-900 dark:text-white">
|
|
|
|
|
|
{value || placeholder}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
|
|
|
|
<ChevronUpDownIcon
|
2025-06-29 15:58:03 -04:00
|
|
|
|
className="h-4 w-4 text-neutral-400"
|
2025-06-29 13:43:46 -04:00
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Listbox.Button>
|
|
|
|
|
|
<Transition
|
|
|
|
|
|
as="div"
|
|
|
|
|
|
leave="transition ease-in duration-100"
|
|
|
|
|
|
leaveFrom="opacity-100"
|
|
|
|
|
|
leaveTo="opacity-0"
|
2025-06-29 15:58:03 -04:00
|
|
|
|
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
2025-06-29 13:43:46 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Listbox.Options>
|
|
|
|
|
|
{options.map((option, optionIdx) => (
|
|
|
|
|
|
<Listbox.Option
|
|
|
|
|
|
key={optionIdx}
|
|
|
|
|
|
className={({ active }) =>
|
|
|
|
|
|
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
|
|
|
|
|
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
|
|
|
|
|
|
}`
|
|
|
|
|
|
}
|
|
|
|
|
|
value={option}
|
|
|
|
|
|
>
|
|
|
|
|
|
{({ selected }) => (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
|
|
|
|
{option}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{selected ? (
|
|
|
|
|
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Listbox.Option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Listbox.Options>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Listbox>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-06-29 15:58:03 -04:00
|
|
|
|
// Map product categories to checklist component categories
|
|
|
|
|
|
const getComponentCategory = (productCategory: string): string => {
|
|
|
|
|
|
const categoryMap: Record<string, string> = {
|
|
|
|
|
|
// Upper components
|
|
|
|
|
|
'Upper Receiver': 'Upper',
|
|
|
|
|
|
'Barrel': 'Upper',
|
|
|
|
|
|
'BCG': 'Upper',
|
|
|
|
|
|
'Bolt Carrier Group': 'Upper',
|
|
|
|
|
|
'Charging Handle': 'Upper',
|
|
|
|
|
|
'Gas Block': 'Upper',
|
|
|
|
|
|
'Gas Tube': 'Upper',
|
|
|
|
|
|
'Handguard': 'Upper',
|
|
|
|
|
|
'Muzzle Device': 'Upper',
|
|
|
|
|
|
'Suppressor': 'Upper',
|
|
|
|
|
|
|
|
|
|
|
|
// Lower components
|
|
|
|
|
|
'Lower Receiver': 'Lower',
|
|
|
|
|
|
'Trigger': 'Lower',
|
|
|
|
|
|
'Trigger Guard': 'Lower',
|
|
|
|
|
|
'Pistol Grip': 'Lower',
|
|
|
|
|
|
'Buffer Tube': 'Lower',
|
|
|
|
|
|
'Buffer': 'Lower',
|
|
|
|
|
|
'Buffer Spring': 'Lower',
|
|
|
|
|
|
'Stock': 'Lower',
|
|
|
|
|
|
|
|
|
|
|
|
// Accessories
|
|
|
|
|
|
'Magazine': 'Accessory',
|
|
|
|
|
|
'Sights': 'Accessory',
|
|
|
|
|
|
'Optic': 'Accessory',
|
|
|
|
|
|
'Scope': 'Accessory',
|
|
|
|
|
|
'Red Dot': 'Accessory',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Map product categories to specific checklist component names
|
|
|
|
|
|
const getMatchingComponentName = (productCategory: string): string => {
|
|
|
|
|
|
const componentMap: Record<string, string> = {
|
|
|
|
|
|
'Upper Receiver': 'Upper Receiver',
|
|
|
|
|
|
'Barrel': 'Barrel',
|
|
|
|
|
|
'BCG': 'Bolt Carrier Group (BCG)',
|
|
|
|
|
|
'Bolt Carrier Group': 'Bolt Carrier Group (BCG)',
|
|
|
|
|
|
'Charging Handle': 'Charging Handle',
|
|
|
|
|
|
'Gas Block': 'Gas Block',
|
|
|
|
|
|
'Gas Tube': 'Gas Tube',
|
|
|
|
|
|
'Handguard': 'Handguard',
|
|
|
|
|
|
'Muzzle Device': 'Muzzle Device',
|
|
|
|
|
|
'Suppressor': 'Muzzle Device', // Suppressors go to Muzzle Device component
|
|
|
|
|
|
|
|
|
|
|
|
'Lower Receiver': 'Lower Receiver',
|
|
|
|
|
|
'Trigger': 'Trigger',
|
|
|
|
|
|
'Trigger Guard': 'Trigger Guard',
|
|
|
|
|
|
'Pistol Grip': 'Pistol Grip',
|
|
|
|
|
|
'Buffer Tube': 'Buffer Tube',
|
|
|
|
|
|
'Buffer': 'Buffer',
|
|
|
|
|
|
'Buffer Spring': 'Buffer Spring',
|
|
|
|
|
|
'Stock': 'Stock',
|
|
|
|
|
|
|
|
|
|
|
|
'Magazine': 'Magazine',
|
|
|
|
|
|
'Sights': 'Sights',
|
|
|
|
|
|
'Optic': 'Sights',
|
|
|
|
|
|
'Scope': 'Sights',
|
|
|
|
|
|
'Red Dot': 'Sights',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return componentMap[productCategory] || '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-06-29 13:43:46 -04:00
|
|
|
|
export default function Home() {
|
|
|
|
|
|
const searchParams = useSearchParams();
|
2025-06-29 15:58:03 -04:00
|
|
|
|
const router = useRouter();
|
2025-06-29 13:43:46 -04:00
|
|
|
|
const [selectedCategory, setSelectedCategory] = 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');
|
2025-06-29 15:58:03 -04:00
|
|
|
|
const [addedPartIds, setAddedPartIds] = useState<string[]>([]);
|
|
|
|
|
|
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
|
|
|
|
|
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
|
|
|
|
|
|
const selectedParts = useBuildStore((state) => state.selectedParts);
|
|
|
|
|
|
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
|
2025-06-29 13:43:46 -04:00
|
|
|
|
|
|
|
|
|
|
// Read category from URL parameter on page load
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const categoryParam = searchParams.get('category');
|
|
|
|
|
|
if (categoryParam && categories.includes(categoryParam)) {
|
|
|
|
|
|
setSelectedCategory(categoryParam);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [searchParams]);
|
|
|
|
|
|
|
|
|
|
|
|
// Filter parts based on selected criteria
|
|
|
|
|
|
const filteredParts = mockProducts.filter(part => {
|
|
|
|
|
|
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory;
|
|
|
|
|
|
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
|
|
|
|
|
|
let matchesRestriction = true;
|
|
|
|
|
|
if (selectedRestriction) {
|
|
|
|
|
|
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
|
|
|
|
|
|
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
|
2025-06-29 15:58:03 -04:00
|
|
|
|
else if (selectedRestriction === 'Suppressor') matchesRestriction = !!part.restrictions?.suppressor;
|
|
|
|
|
|
else if (selectedRestriction === 'State Restrictions') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
|
2025-06-29 13:43:46 -04:00
|
|
|
|
else 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 && 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
|
|
|
|
|
setSelectedCategory('All');
|
|
|
|
|
|
setSelectedBrand('All');
|
|
|
|
|
|
setSelectedVendor('All');
|
|
|
|
|
|
setSearchTerm('');
|
|
|
|
|
|
setPriceRange('');
|
|
|
|
|
|
setSelectedRestriction('');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const hasActiveFilters = selectedCategory !== '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');
|
2025-06-29 15:58:03 -04:00
|
|
|
|
if (restrictions?.suppressor) flags.push('Suppressor');
|
|
|
|
|
|
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('State Restrictions');
|
2025-06-29 13:43:46 -04:00
|
|
|
|
return flags;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-06-29 15:58:03 -04:00
|
|
|
|
const handleAdd = (part: Product) => {
|
|
|
|
|
|
setAddedPartIds((prev) => [...prev, part.id]);
|
|
|
|
|
|
setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-06-29 13:43:46 -04:00
|
|
|
|
return (
|
|
|
|
|
|
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
|
|
|
|
|
|
{/* Page Title */}
|
|
|
|
|
|
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
|
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
|
|
|
|
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
|
|
|
|
|
|
Parts Catalog
|
|
|
|
|
|
{selectedCategory !== 'All' && (
|
|
|
|
|
|
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
|
|
|
|
|
|
- {selectedCategory}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
<p className="text-neutral-600 dark:text-neutral-400 mt-2">
|
|
|
|
|
|
{selectedCategory !== 'All'
|
|
|
|
|
|
? `Showing ${selectedCategory} parts for your build`
|
|
|
|
|
|
: 'Browse and filter firearm parts for your build'
|
|
|
|
|
|
}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Search and Filters */}
|
|
|
|
|
|
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
{/* Search Row */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<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-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 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-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
|
|
|
|
|
aria-label="Open search"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MagnifyingGlassIcon className="h-5 w-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Filters Row */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
{/* Category Dropdown */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="col-span-1">
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
label="Category"
|
|
|
|
|
|
value={selectedCategory}
|
|
|
|
|
|
onChange={setSelectedCategory}
|
|
|
|
|
|
options={categories}
|
|
|
|
|
|
placeholder="All categories"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-06-29 13:43:46 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Brand Dropdown */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="col-span-1">
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
label="Brand"
|
|
|
|
|
|
value={selectedBrand}
|
|
|
|
|
|
onChange={setSelectedBrand}
|
|
|
|
|
|
options={brands}
|
|
|
|
|
|
placeholder="All brands"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-06-29 13:43:46 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Vendor Dropdown */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="col-span-1">
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
label="Vendor"
|
|
|
|
|
|
value={selectedVendor}
|
|
|
|
|
|
onChange={setSelectedVendor}
|
|
|
|
|
|
options={vendors}
|
|
|
|
|
|
placeholder="All vendors"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-06-29 13:43:46 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Price Range */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="col-span-1">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
<Listbox value={priceRange} onChange={setPriceRange}>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
|
|
|
|
Price Range
|
|
|
|
|
|
</Listbox.Label>
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
<span className="block truncate text-neutral-900 dark:text-white">
|
|
|
|
|
|
{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
|
2025-06-29 15:58:03 -04:00
|
|
|
|
className="h-4 w-4 text-neutral-400"
|
2025-06-29 13:43:46 -04:00
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Listbox.Button>
|
|
|
|
|
|
<Transition
|
|
|
|
|
|
as="div"
|
|
|
|
|
|
leave="transition ease-in duration-100"
|
|
|
|
|
|
leaveFrom="opacity-100"
|
|
|
|
|
|
leaveTo="opacity-0"
|
2025-06-29 15:58:03 -04:00
|
|
|
|
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
2025-06-29 13:43:46 -04:00
|
|
|
|
>
|
|
|
|
|
|
<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-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
|
|
|
|
|
|
}`
|
|
|
|
|
|
}
|
|
|
|
|
|
value={option.value}
|
|
|
|
|
|
>
|
|
|
|
|
|
{({ selected }) => (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{selected ? (
|
|
|
|
|
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Listbox.Option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Listbox.Options>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Listbox>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Restriction Filter */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="col-span-1">
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
label="Restriction"
|
|
|
|
|
|
value={selectedRestriction}
|
|
|
|
|
|
onChange={setSelectedRestriction}
|
|
|
|
|
|
options={restrictionOptions}
|
|
|
|
|
|
placeholder="All restrictions"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-06-29 13:43:46 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Clear Filters */}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="col-span-1 flex items-end">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={clearFilters}
|
|
|
|
|
|
disabled={!hasActiveFilters}
|
2025-06-29 15:58:03 -04:00
|
|
|
|
className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
|
2025-06-29 13:43:46 -04:00
|
|
|
|
hasActiveFilters
|
|
|
|
|
|
? 'bg-accent-600 hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600 text-white'
|
|
|
|
|
|
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 cursor-not-allowed'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<XMarkIcon className="h-3.5 w-3.5" />
|
2025-06-29 13:43:46 -04:00
|
|
|
|
Clear All
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Parts Display */}
|
|
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
|
|
{/* View Toggle and Results Count */}
|
|
|
|
|
|
<div className="flex justify-between items-center mb-6">
|
|
|
|
|
|
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
|
|
|
|
Showing {sortedParts.length} of {mockProducts.length} parts
|
|
|
|
|
|
{hasActiveFilters && (
|
|
|
|
|
|
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
|
|
|
|
|
(filtered)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* View Toggle */}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">View:</span>
|
|
|
|
|
|
<div className="btn-group">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
|
|
|
|
|
|
onClick={() => setViewMode('table')}
|
2025-06-29 14:07:26 -04:00
|
|
|
|
aria-label="Table view"
|
2025-06-29 13:43:46 -04:00
|
|
|
|
>
|
2025-06-29 14:07:26 -04:00
|
|
|
|
<TableCellsIcon className="h-5 w-5" />
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
|
|
|
|
|
|
onClick={() => setViewMode('cards')}
|
2025-06-29 14:07:26 -04:00
|
|
|
|
aria-label="Card view"
|
2025-06-29 13:43:46 -04:00
|
|
|
|
>
|
2025-06-29 14:07:26 -04:00
|
|
|
|
<Squares2X2Icon className="h-5 w-5" />
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Table View */}
|
|
|
|
|
|
{viewMode === 'table' && (
|
|
|
|
|
|
<div className="bg-white dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<div className="overflow-x-auto max-h-screen overflow-y-auto">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<thead className="bg-neutral-50 dark:bg-neutral-700 sticky top-0 z-10 shadow-sm">
|
2025-06-29 13:43:46 -04:00
|
|
|
|
<tr>
|
|
|
|
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
2025-06-29 14:07:26 -04:00
|
|
|
|
Product
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</th>
|
|
|
|
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
2025-06-29 14:07:26 -04:00
|
|
|
|
Category
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</th>
|
|
|
|
|
|
<th
|
|
|
|
|
|
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
|
|
|
|
|
|
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-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
|
|
|
|
|
|
Actions
|
|
|
|
|
|
</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
|
|
|
|
|
{sortedParts.map((part) => (
|
|
|
|
|
|
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
|
2025-06-29 14:07:26 -04:00
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap flex items-center gap-3 min-w-[180px]">
|
|
|
|
|
|
<div className="w-12 h-12 flex-shrink-0 rounded bg-neutral-100 dark:bg-neutral-700 overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-700">
|
|
|
|
|
|
<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" />
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</div>
|
2025-06-29 14:07:26 -04:00
|
|
|
|
<div>
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<Link href={`/products/${part.id}`} className="text-sm font-semibold text-primary hover:underline dark:text-primary-400">
|
|
|
|
|
|
{part.name}
|
|
|
|
|
|
</Link>
|
2025-06-29 14:07:26 -04:00
|
|
|
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</div>
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
2025-06-29 14:07:26 -04:00
|
|
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
|
|
|
|
|
{part.category.name}
|
|
|
|
|
|
</span>
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
|
|
|
|
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
|
|
|
|
|
|
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
2025-06-29 15:58:03 -04:00
|
|
|
|
{(() => {
|
|
|
|
|
|
// 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="btn btn-outline btn-sm"
|
|
|
|
|
|
onClick={() => removePartForComponent(selectedComponentId)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Remove
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
2025-06-30 06:36:03 -04:00
|
|
|
|
className="btn btn-neutral btn-sm flex items-center gap-1"
|
2025-06-29 15:58:03 -04:00
|
|
|
|
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');
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-06-30 06:36:03 -04:00
|
|
|
|
<span className="text-lg leading-none">+</span>
|
|
|
|
|
|
<span className="text-xs font-normal">to build</span>
|
2025-06-29 15:58:03 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="text-xs text-gray-400">Part Selected</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
})()}
|
2025-06-29 13:43:46 -04:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Table Footer */}
|
|
|
|
|
|
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
|
|
|
|
Showing {sortedParts.length} of {mockProducts.length} parts
|
|
|
|
|
|
{hasActiveFilters && (
|
|
|
|
|
|
<span className="ml-2 text-primary-600 dark:text-primary-400">
|
|
|
|
|
|
(filtered)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
|
|
|
|
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card View */}
|
|
|
|
|
|
{viewMode === 'cards' && (
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
|
|
|
|
{sortedParts.map((part) => (
|
2025-06-29 15:58:03 -04:00
|
|
|
|
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
|
2025-06-29 13:43:46 -04:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Compact Restriction Legend */}
|
|
|
|
|
|
<div className="mt-8 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
|
|
|
|
|
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|